From e273c995a59271004f54ee1cb65f4e161554f900 Mon Sep 17 00:00:00 2001 From: ssh-monitor Date: Tue, 14 Apr 2026 12:20:04 +1000 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20?= =?UTF-8?q?Zabbix;=20=D0=BE=D1=81=D1=82=D0=B0=D0=BB=D0=B8=D1=81=D1=8C=20Te?= =?UTF-8?q?legram=20=D0=B8=20email=20(SMTP).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 31 +--- ssh-monitor | 313 +++------------------------------------ ssh-monitor.conf.example | 23 +-- 3 files changed, 35 insertions(+), 332 deletions(-) diff --git a/README.md b/README.md index b44b490..889fbf1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ssh-monitor -Bash-скрипт мониторинга **SSH**, **SUDO** и событий **`systemd-logind`** (локальные/графические сессии и др.) с уведомлениями в Telegram, опциональным резервным webhook, авто-блокировкой IP (IPv4 через `iptables`, IPv6 через `ip6tables` при наличии) и ежедневным отчётом. +Bash-скрипт мониторинга **SSH**, **SUDO** и событий **`systemd-logind`** (локальные/графические сессии и др.) с уведомлениями в **Telegram** и по **электронной почте (SMTP)**, опциональным резервным webhook, авто-блокировкой IP (IPv4 через `iptables`, IPv6 через `ip6tables` при наличии) и ежедневным отчётом. ## Конфигурация @@ -12,15 +12,15 @@ Bash-скрипт мониторинга **SSH**, **SUDO** и событий **` - `sudo chmod 600 /etc/ssh-monitor.conf` 3. Заполните минимум — **хотя бы один канал доставки оповещений** (иначе скрипт сразу завершится с ошибкой; см. абзац **«Обязательное условие»** ниже и раздел **«Каналы оповещений и пустой NOTIFY_CHAIN»**): - для **Telegram**: `TELEGRAM_BOT_TOKEN` и `TELEGRAM_CHAT_ID`; - - или настройте **Zabbix** / **почту** по переменным ниже при `NOTIFY_ORDER=""` (автовыбор каналов). + - или настройте **почту (SMTP)** по переменным ниже при `NOTIFY_ORDER=""` (автовыбор каналов). -**Обязательное условие:** после загрузки конфигурации в цепочке **`NOTIFY_CHAIN`** должен быть **минимум один** канал (`telegram`, `zabbix` или `email`). Если каналов **нет** (пустой `NOTIFY_ORDER` и ни Telegram, ни Zabbix, ни SMTP не удовлетворяют критериям «настроен», либо в `NOTIFY_ORDER` остались только неизвестные имена), скрипт **не входит** в основной цикл и завершается с кодом **1**, в stderr: **`Не настроен ни один канал отправки оповещений`**. Это же правило действует для **`--check-config`** и **`--dry-run`**. Резервный **`BACKUP_WEBHOOK_URL`** в эту проверку **не входит** — он используется только если **ни один** канал из **`NOTIFY_CHAIN`** не смог доставить сообщение. +**Обязательное условие:** после загрузки конфигурации в цепочке **`NOTIFY_CHAIN`** должен быть **минимум один** канал (`telegram` или `email`). Если каналов **нет** (пустой `NOTIFY_ORDER` и ни Telegram, ни SMTP не удовлетворяют критериям «настроен», либо в `NOTIFY_ORDER` остались только неизвестные имена), скрипт **не входит** в основной цикл и завершается с кодом **1**, в stderr: **`Не настроен ни один канал отправки оповещений`**. Это же правило действует для **`--check-config`** и **`--dry-run`**. Резервный **`BACKUP_WEBHOOK_URL`** в эту проверку **не входит** — он используется только если **ни один** канал из **`NOTIFY_CHAIN`** не смог доставить сообщение. Поддерживаемые параметры: - `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID` -- `NOTIFY_ORDER` — список каналов (`telegram`, `zabbix`, `email` и сокращения **tg**, **zbx**, **mail**); при каждом оповещении скрипт **пытается отправить во все перечисленные** каналы по порядку (не «первый успешный — и стоп»). Пусто = в цепочку попадают только настроенные каналы (порядок по умолчанию см. в `ssh-monitor.conf.example`). Если итоговая цепочка пуста — скрипт не стартует (см. раздел **«Каналы оповещений и пустой NOTIFY_CHAIN»**). -- `ZABBIX_SERVER`, `ZABBIX_HOST_NAME`, `ZABBIX_ALERT_KEY`, `ZABBIX_SEQ_KEY` — отправка в Zabbix через `zabbix_sender`; подробнее — раздел **«Zabbix (`zabbix_sender`)»** ниже +- `NOTIFY_ORDER` — список каналов (`telegram`, `email` и сокращения **tg**, **mail**); при каждом оповещении скрипт **пытается отправить во все перечисленные** каналы по порядку (не «первый успешный — и стоп»). Пусто = в цепочку попадают только настроенные каналы (порядок по умолчанию см. в `ssh-monitor.conf.example`). Если итоговая цепочка пуста — скрипт не стартует (см. раздел **«Каналы оповещений и пустой NOTIFY_CHAIN»**). +- `MAIL_SMTP_HOST`, `MAIL_SMTP_PORT`, `MAIL_SMTP_USER`, `MAIL_SMTP_PASSWORD`, `MAIL_FROM`, `MAIL_TO`, `MAIL_SMTP_STARTTLS`, `MAIL_SMTP_SSL` — отправка почты через **python3** (см. таблицу каналов ниже) - `BACKUP_WEBHOOK_URL` — резервная доставка JSON `{"text":"..."}` (например Slack Incoming Webhook), если **все** каналы из **`NOTIFY_CHAIN`** не смогли доставить сообщение; **не заменяет** обязательность хотя бы одного основного канала (см. шаг 3) - `LOG_FILE`, `LAST_HEARTBEAT_FILE`, `LAST_REPORT_FILE`, `LAST_SSH_CHECK_FILE`, `LAST_SUDO_CHECK_FILE`, `LAST_SECURITY_EVENTS_FILE`, `LAST_LOGIND_CHECK_FILE`, `BAN_LIST_FILE` - `ENABLE_LOGIND_MONITOR`, `LOGIND_NOTIFY_NEW`, `LOGIND_NOTIFY_REMOVED`, `LOGIND_NOTIFY_FAILED`, `LOGIND_SKIP_REMOTE` — см. раздел «Мониторинг systemd-logind» ниже @@ -38,11 +38,8 @@ Bash-скрипт мониторинга **SSH**, **SUDO** и событий **` - `TELEGRAM_BOT_TOKEN` — токен Telegram-бота для отправки уведомлений. - `TELEGRAM_CHAT_ID` — ID чата/пользователя, куда отправляются уведомления. -- `NOTIFY_ORDER` — CSV имён каналов (`telegram`, `zabbix`, `email` или `tg`, `zbx`, `mail`). Для **каждого** оповещения выполняется попытка доставки **во все** перечисленные каналы (при ошибке одного остальные всё равно пробуются; неудачи пишутся в **`LOG_FILE`**). Пустая строка: автоматически собирается цепочка только из реально настроенных каналов (по умолчанию порядок telegram → zabbix → email; критерии «настроен» — **таблица** в разделе **«Каналы оповещений и пустой NOTIFY_CHAIN»**). Если после сборки цепочки **нет ни одного** канала, скрипт **сразу завершается** с ошибкой: **«Не настроен ни один канал отправки оповещений»** (в том числе режим `--check-config` и `--dry-run`). -- `ZABBIX_SERVER` — имя или IP сервера Zabbix для `zabbix_sender -z` (пусто = канал Zabbix отключён). -- `ZABBIX_HOST_NAME` — имя **хоста в Zabbix**, как в конфигурации агента/шаблона (`-s` у `zabbix_sender`); должно совпадать с тем, для какого хоста созданы trapper-элементы. -- `ZABBIX_ALERT_KEY` — ключ **первого** trapper-элемента: в него уходит **текст** оповещения (одна строка, переводы строк заменены пробелами). -- `ZABBIX_SEQ_KEY` — ключ **второго** trapper-элемента: в него при каждой отправке пишется **монотонно растущий счётчик** (1, 2, 3, … за время работы процесса скрипта). Значение по умолчанию — `ssh.monitor.seq`. Назначение: чтобы Zabbix и триггеры видели **новое значение** даже при **одинаковом** тексте алерта (иначе повтор с тем же текстом может плохо отражаться на логике «изменилось ли значение»); также по счётчику удобнее отслеживать порядок событий. В шаблоне Zabbix нужны **два** элемента типа **Zabbix trapper** с ключами, совпадающими с `ZABBIX_ALERT_KEY` и `ZABBIX_SEQ_KEY` (второй — обычно **числовой**). +- `NOTIFY_ORDER` — CSV имён каналов (`telegram`, `email` или `tg`, `mail`). Для **каждого** оповещения выполняется попытка доставки **во все** перечисленные каналы (при ошибке одного остальные всё равно пробуются; неудачи пишутся в **`LOG_FILE`**). Пустая строка: автоматически собирается цепочка только из реально настроенных каналов (по умолчанию порядок telegram → email; критерии «настроен» — **таблица** в разделе **«Каналы оповещений и пустой NOTIFY_CHAIN»**). Если после сборки цепочки **нет ни одного** канала, скрипт **сразу завершается** с ошибкой: **«Не настроен ни один канал отправки оповещений»** (в том числе режим `--check-config` и `--dry-run`). +- `MAIL_SMTP_HOST`, `MAIL_SMTP_PORT`, `MAIL_SMTP_USER`, `MAIL_SMTP_PASSWORD`, `MAIL_FROM`, `MAIL_TO`, `MAIL_SMTP_STARTTLS`, `MAIL_SMTP_SSL` — параметры SMTP для канала `email` (отправка через **python3**; см. таблицу «настроен»). - `BACKUP_WEBHOOK_URL` — URL для резервной отправки (тело JSON `{"text":"..."}`); только fallback при сбое основных каналов, **не засчитывается** как «настроенный канал» (см. шаг 3). - `LOG_FILE` — путь к основному лог-файлу скрипта. - `LAST_HEARTBEAT_FILE` — файл с timestamp последнего heartbeat-сообщения. @@ -59,7 +56,7 @@ Bash-скрипт мониторинга **SSH**, **SUDO** и событий **` - `LOGIND_SKIP_REMOTE` — `1` (по умолчанию): для новой сессии logind, если доступен `loginctl`, не отправляется второе Telegram, если сессия уже учтена в **`monitor_ssh`**: при **`Type=ssh`** или если **`Service`** указывает на **sshd** (часто **`Type=tty` + `Service=sshd`**); при пустом ответе `loginctl` делаются короткие повторы. Поставьте `0`, если нужны отдельные алерты logind и для SSH-сессий. - `DAILY_REPORT_HOUR` — час (0..23), после наступления которого в **текущих календарных сутках** (в выбранной ниже зоне) отправляется не более одного ежедневного отчёта. - `DAILY_REPORT_TZ` — необязательная **IANA**-зона (`Europe/Moscow`, `Asia/Yekaterinburg`, …). Если **пусто**, для отчёта используется та же зона, что и у команды `date` у процесса монитора (как правило, совпадает с `timedatectl` / `/etc/localtime` на сервере). Если сервис запускается с `TZ=UTC` в unit-файле, без `DAILY_REPORT_TZ` отчёт ориентируется на **UTC** — тогда задайте явную зону в конфиге. -- `NOTIFY_TZ` — **IANA**-зона для строк **«🕐 Время»** в Telegram/Zabbix/email (и аналогичных текстах). **Пусто** — используется **`DAILY_REPORT_TZ`**, если она задана, иначе зона процесса. Частая причина «время в теле сообщения UTC, а в заголовке чата локальное»: в **`ssh-monitor.service`** задано **`Environment=TZ=UTC`** — тогда задайте **`NOTIFY_TZ`** (или **`DAILY_REPORT_TZ`**) на вашу локаль, например `Asia/Vladivostok`. +- `NOTIFY_TZ` — **IANA**-зона для строк **«🕐 Время»** в Telegram/email (и аналогичных текстах). **Пусто** — используется **`DAILY_REPORT_TZ`**, если она задана, иначе зона процесса. Частая причина «время в теле сообщения UTC, а в заголовке чата локальное»: в **`ssh-monitor.service`** задано **`Environment=TZ=UTC`** — тогда задайте **`NOTIFY_TZ`** (или **`DAILY_REPORT_TZ`**) на вашу локаль, например `Asia/Vladivostok`. - `SSH_ACCEPT_NOTIFY_DEDUP_SEC` — не чаще одного Telegram по **успешному SSH (`Accepted`)** на одну пару **пользователь + IP** за указанное число **секунд** (по умолчанию **5**). Снимает дубли, когда в journal две строки с **разными портами клиента** за один вход. **`0`** — отключить этот антидубль (останется только дедупликация внутри одного прохода `monitor_ssh` по `user|ip|port`). - `DAILY_REPORT_TOP_IPS` — сколько IP показывать в топе неудачных попыток за 24 часа. - `BRUTE_WINDOW_SEC` — окно (секунды) для оценки «массового» брутфорса по `journalctl`. @@ -85,22 +82,10 @@ Bash-скрипт мониторинга **SSH**, **SUDO** и событий **` | Канал | Условие «настроен» | |--------|-------------------| | `telegram` | заданы **`TELEGRAM_BOT_TOKEN`** и **`TELEGRAM_CHAT_ID`** | -| `zabbix` | непустые **`ZABBIX_SERVER`**, **`ZABBIX_HOST_NAME`**, в PATH есть **`zabbix_sender`** | | `email` | непустые **`MAIL_SMTP_HOST`**, **`MAIL_FROM`**, **`MAIL_TO`**, в PATH есть **`python3`** | Если итоговый **`NOTIFY_CHAIN` пуст**, скрипт завершается с сообщением **`Не настроен ни один канал отправки оповещений`** (см. шаг 3 в разделе «Конфигурация» выше). -### Zabbix (`zabbix_sender`) - -Канал активен, если в **`NOTIFY_CHAIN`** есть `zabbix`, заданы **`ZABBIX_SERVER`** и **`ZABBIX_HOST_NAME`**, в PATH есть **`zabbix_sender`**. - -При отправке формируется временный файл с **двумя** строками в формате `ключзначение`: - -1. **`ZABBIX_ALERT_KEY`** (по умолчанию `ssh.monitor.alert`) — текст сообщения. -2. **`ZABBIX_SEQ_KEY`** (по умолчанию `ssh.monitor.seq`) — целое **число-счётчик**, увеличивается на 1 при каждой отправке в Zabbix. - -Имеет смысл завести в Zabbix на соответствующем хосте **два trapper-элемента** с этими ключами. Ключ **`ZABBIX_SEQ_KEY`** можно переименовать в конфиге, если в вашем шаблоне приняты другие имена; смысл остаётся тем же: отдельный item под **последовательность**, а не под человекочитаемый текст. - ### Мониторинг systemd-logind - Собираются сообщения юнита **`systemd-logind`** через **`journalctl`** (`-o cat`): новые и завершённые сессии, а также строки с подстрокой *failed* (если включено). diff --git a/ssh-monitor b/ssh-monitor index f0dfa1c..ff488b4 100644 --- a/ssh-monitor +++ b/ssh-monitor @@ -7,26 +7,16 @@ IFS=$'\n\t' # ============================================ CONFIG_FILE="/etc/ssh-monitor.conf" -SSH_MONITOR_VERSION="0.9.7.1" +SSH_MONITOR_VERSION="0.10.0.0" TELEGRAM_BOT_TOKEN="" TELEGRAM_CHAT_ID="" BACKUP_WEBHOOK_URL="" -# Очередь оповещений: CSV из имён telegram, zabbix, email (или tg, zbx, mail). -# Пусто = авто: только настроенные каналы, порядок telegram → zabbix → email. +# Очередь оповещений: CSV из имён telegram, email (или tg, mail). +# Пусто = авто: только настроенные каналы, порядок telegram → email. NOTIFY_ORDER="" -# Zabbix trapper (zabbix_sender). Пустой ZABBIX_SERVER — канал недоступен. -ZABBIX_SERVER="" -ZABBIX_HOST_NAME="" -ZABBIX_ALERT_KEY="ssh.monitor.alert" -ZABBIX_SEQ_KEY="ssh.monitor.seq" -# Лимит UTF-8 байт для значения ssh.monitor.alert (0 = не урезать). Для короткого plain-текста в Zabbix обычно 0. -ZABBIX_ALERT_MAX_BYTES="0" -# Файл последнего ssh.monitor.seq (число), чтобы после перезапуска счётчик не сбрасывался в 1. -ZABBIX_SEQ_STATE_FILE="/var/log/ssh_monitor_zabbix_seq" - # Почта (SMTP через python3). Нужны MAIL_SMTP_HOST, MAIL_FROM, MAIL_TO. MAIL_SMTP_HOST="" MAIL_SMTP_PORT="587" @@ -46,7 +36,7 @@ DAILY_REPORT_TOP_IPS=5 # Пусто = календарь и час отчёта в зоне процесса (как `date`, обычно совпадает с /etc/localtime). # Иначе IANA, например Europe/Moscow — явная зона для DAILY_REPORT_HOUR и даты «сегодня» в отчёте. DAILY_REPORT_TZ="" -# IANA-зона для строк «🕐 Время» в Telegram/Zabbix/email. Пусто = тогда DAILY_REPORT_TZ, если она задана, иначе зона процесса (часто UTC у systemd — см. README). +# IANA-зона для строк «🕐 Время» в Telegram/email. Пусто = тогда DAILY_REPORT_TZ, если она задана, иначе зона процесса (часто UTC у systemd — см. README). NOTIFY_TZ="" # Не слать повтор «Accepted» для той же пары user+IP чаще, чем раз в N секунд (разные port в журнале — один вход). SSH_ACCEPT_NOTIFY_DEDUP_SEC=5 @@ -99,7 +89,6 @@ CHECK_CONFIG_ONLY=0 DRY_RUN=0 # Защита от двойного вызова: при SIGTERM срабатывает trap TERM, затем при выходе — trap EXIT SHUTDOWN_NOTIFIED=0 -ZABBIX_EVENT_SEQ=0 declare -a NOTIFY_CHAIN=() trim_value() { @@ -157,12 +146,6 @@ load_config() { 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_zabbix_server="${ZABBIX_SERVER:-}" - local cfg_zabbix_host_name="${ZABBIX_HOST_NAME:-}" - local cfg_zabbix_alert_key="${ZABBIX_ALERT_KEY:-ssh.monitor.alert}" - local cfg_zabbix_seq_key="${ZABBIX_SEQ_KEY:-ssh.monitor.seq}" - local cfg_zabbix_alert_max_bytes="${ZABBIX_ALERT_MAX_BYTES:-0}" - local cfg_zabbix_seq_state_file="${ZABBIX_SEQ_STATE_FILE:-/var/log/ssh_monitor_zabbix_seq}" local cfg_log_file="${LOG_FILE}" local cfg_last_heartbeat_file="${LAST_HEARTBEAT_FILE}" local cfg_last_report_file="${LAST_REPORT_FILE}" @@ -212,16 +195,6 @@ load_config() { 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}" - ZABBIX_SERVER="$(trim_value "${ZABBIX_SERVER:-$cfg_zabbix_server}")" - ZABBIX_HOST_NAME="$(trim_value "${ZABBIX_HOST_NAME:-$cfg_zabbix_host_name}")" - ZABBIX_ALERT_KEY="$(trim_value "${ZABBIX_ALERT_KEY:-$cfg_zabbix_alert_key}")" - ZABBIX_SEQ_KEY="$(trim_value "${ZABBIX_SEQ_KEY:-$cfg_zabbix_seq_key}")" - ZABBIX_ALERT_MAX_BYTES="$(trim_value "${ZABBIX_ALERT_MAX_BYTES:-$cfg_zabbix_alert_max_bytes}")" - if ! [[ "${ZABBIX_ALERT_MAX_BYTES:-0}" =~ ^[0-9]+$ ]]; then - printf 'WARN: ZABBIX_ALERT_MAX_BYTES="%s" не число, использую 0\n' "${ZABBIX_ALERT_MAX_BYTES:-}" >&2 - ZABBIX_ALERT_MAX_BYTES=0 - fi - ZABBIX_SEQ_STATE_FILE="$(trim_value "${ZABBIX_SEQ_STATE_FILE:-$cfg_zabbix_seq_state_file}")" 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}" @@ -268,8 +241,6 @@ load_config() { 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}" - - _sshmon_zbx_load_seq_state } # Дата/время для ежедневного отчёта (час DAILY_REPORT_HOUR и «сегодня» в LAST_REPORT_FILE). @@ -296,15 +267,11 @@ is_telegram_configured() { [ -n "${TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${TELEGRAM_CHAT_ID:-}" ] } -is_zabbix_configured() { - [ -n "${ZABBIX_SERVER:-}" ] && [ -n "${ZABBIX_HOST_NAME:-}" ] && command -v zabbix_sender >/dev/null 2>&1 -} - 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 → zabbix → email). +# Заполняет NOTIFY_CHAIN: при пустом NOTIFY_ORDER — только реально настроенные каналы (telegram → email). # При непустом NOTIFY_ORDER — ровно указанный список (имена каналов), без добавления «лишних»; notify_send шлёт во ВСЕ каналы из списка. refresh_notify_chain() { NOTIFY_CHAIN=() @@ -315,9 +282,6 @@ refresh_notify_chain() { if is_telegram_configured; then NOTIFY_CHAIN+=(telegram) fi - if is_zabbix_configured; then - NOTIFY_CHAIN+=(zabbix) - fi if is_email_configured; then NOTIFY_CHAIN+=(email) fi @@ -331,7 +295,6 @@ refresh_notify_chain() { norm="${tok,,}" case "$norm" in telegram | tg) NOTIFY_CHAIN+=(telegram) ;; - zabbix | zbx) NOTIFY_CHAIN+=(zabbix) ;; email | mail) NOTIFY_CHAIN+=(email) ;; *) printf 'WARN: NOTIFY_ORDER: неизвестный токен «%s», пропускаю\n' "$tok" >&2 @@ -484,7 +447,7 @@ write_log() { } # ============================================ -# УВЕДОМЛЕНИЯ: telegram / zabbix / email — по NOTIFY_CHAIN во все перечисленные каналы; BACKUP_WEBHOOK только если все неуспешны. +# УВЕДОМЛЕНИЯ: telegram / email — по NOTIFY_CHAIN во все перечисленные каналы; BACKUP_WEBHOOK только если все неуспешны. # ============================================ send_email_raw() { @@ -607,201 +570,10 @@ PY return 1 } -# Восстановить счётчик seq из файла (после перезапуска не начинать с 1). -_sshmon_zbx_load_seq_state() { - [ -z "${ZABBIX_SEQ_STATE_FILE:-}" ] && return 0 - [ ! -f "$ZABBIX_SEQ_STATE_FILE" ] && return 0 - local s - s="$(tr -d ' \t\r\n' <"$ZABBIX_SEQ_STATE_FILE" 2>/dev/null || true)" - if [[ "$s" =~ ^[0-9]+$ ]] && [ "$s" -ge "${ZABBIX_EVENT_SEQ:-0}" ]; then - ZABBIX_EVENT_SEQ="$s" - fi -} - -_sshmon_zbx_save_seq_state() { - [ -z "${ZABBIX_SEQ_STATE_FILE:-}" ] && return 0 - local d - d="$(dirname "$ZABBIX_SEQ_STATE_FILE")" - mkdir -p "$d" 2>/dev/null || true - printf '%s\n' "${ZABBIX_EVENT_SEQ:-0}" >"${ZABBIX_SEQ_STATE_FILE}.tmp" 2>/dev/null || return 0 - mv -f "${ZABBIX_SEQ_STATE_FILE}.tmp" "$ZABBIX_SEQ_STATE_FILE" 2>/dev/null || true -} - -# Разбор вывода zabbix_sender (stdout+stderr в zlog). -_sshmon_zbx_parse_sender_output() { - local log_tag="$1" - local zlog="$2" - local zrc="${3:-0}" - if grep -Fq 'Sending failed.' "$zlog" 2>/dev/null; then - write_log "WARN: zabbix_sender ${log_tag}: exit ${zrc}, $(tr '\n\r' ' ' <"$zlog")" - return 1 - fi - if grep -Eq 'failed:[[:space:]]*0[[:space:]]*;' "$zlog" 2>/dev/null; then - return 0 - fi - if [ -s "$zlog" ]; then - write_log "WARN: zabbix_sender ${log_tag}: exit ${zrc}, $(tr '\n\r' ' ' <"$zlog")" - if grep -Eq 'failed:[[:space:]]*[1-9][0-9]*[[:space:]]*;' "$zlog" 2>/dev/null; then - write_log "WARN: Zabbix отклонил значение (${log_tag}): хост, ключ trapper, тип item, препроцессинг или лимит; для длинных текстов — ZABBIX_ALERT_MAX_BYTES." - fi - else - write_log "WARN: zabbix_sender ${log_tag}: exit ${zrc}, вывод пуст (PATH zabbix_sender, сеть до ${ZABBIX_SERVER})" - fi - return 1 -} - -# zabbix_sender с произвольными аргументами (-i файл или -k/-o и т.д.). Весь вывод в zlog. -# Успех только по ответу сервера failed: 0 (exit-код и строка «sent:» часто обманчивы). -_sshmon_zbx_send() { - local log_tag="$1" - local zlog="$2" - shift 2 - [ -n "${ZABBIX_SERVER:-}" ] && [ -n "${ZABBIX_HOST_NAME:-}" ] || return 1 - : >"$zlog" - zabbix_sender -z "$ZABBIX_SERVER" -s "$ZABBIX_HOST_NAME" "$@" >"$zlog" 2>&1 || : - local zrc=$? - _sshmon_zbx_parse_sender_output "$log_tag" "$zlog" "$zrc" -} - -# Укладывает текст в лимит байт UTF-8 (Zabbix trapper «Character»). 0 = без усечения. -_sshmon_zbx_truncate_alert_value() { - local text="$1" - local maxb="${ZABBIX_ALERT_MAX_BYTES:-0}" - case "$maxb" in - ''|*[!0-9]*) maxb=0 ;; - esac - if [ "$maxb" -eq 0 ]; then - printf '%s' "$text" - return 0 - fi - if ! command -v python3 >/dev/null 2>&1; then - if [ "${#text}" -gt 100 ]; then - printf '%s …' "${text:0:100}" - else - printf '%s' "$text" - fi - return 0 - fi - local out - out="$(printf '%s' "$text" | ZABBIX_ALERT_MAX_BYTES="$maxb" python3 -c ' -import os, sys -maxb = int(os.environ["ZABBIX_ALERT_MAX_BYTES"]) -s = sys.stdin.read() -if maxb <= 0: - sys.stdout.write(s) -else: - b = s.encode("utf-8") - if len(b) <= maxb: - sys.stdout.write(s) - else: - b = b[:maxb] - while b and (b[-1] & 0xC0) == 0x80: - b = b[:-1] - sys.stdout.write(b.decode("utf-8")) -' 2>/dev/null)" || out="" - if [ -z "$out" ]; then - if [ "${#text}" -gt 100 ]; then - printf '%s …' "${text:0:100}" - else - printf '%s' "$text" - fi - return 0 - fi - printf '%s' "$out" -} - -send_zabbix_trapper() { - local message="${1:-}" - [ -n "$message" ] || return 1 - [ -n "${ZABBIX_SERVER:-}" ] && [ -n "${ZABBIX_HOST_NAME:-}" ] || return 1 - command -v zabbix_sender >/dev/null 2>&1 || return 1 - - local key_al="${ZABBIX_ALERT_KEY:-ssh.monitor.alert}" - local key_sq="${ZABBIX_SEQ_KEY:-ssh.monitor.seq}" - [ -n "$key_al" ] || key_al="ssh.monitor.alert" - [ -n "$key_sq" ] || key_sq="ssh.monitor.seq" - key_al="$(printf '%s' "$key_al" | tr -d '\r')" - key_sq="$(printf '%s' "$key_sq" | tr -d '\r')" - - local one_line - # NUL в значении обрезает файл у первого \0 — Zabbix часто отвечает failed: 1 - one_line="$(printf '%s' "$message" | tr -d '\000' | tr '\n\r\t' ' ')" - local max_len=60000 - if [ "${#one_line}" -gt "$max_len" ]; then - one_line="${one_line:0:$max_len}..." - fi - one_line="$(_sshmon_zbx_truncate_alert_value "$one_line")" - - ZABBIX_EVENT_SEQ=$((ZABBIX_EVENT_SEQ + 1)) - - local zlog alert_rc seq_rc tmp="" - zlog="$(mktemp "${TMPDIR:-/tmp}/ssh-monitor.zbx.log.XXXXXX" 2>/dev/null)" || return 1 - - alert_rc=1 - seq_rc=1 - # UTF-8 в -o через python3 (argv) надёжнее, чем -i файл на части сборок zabbix_sender. - if command -v python3 >/dev/null 2>&1; then - : >"$zlog" - # ZABBIX_* должны быть в окружении python3 (в bash без export они не видны в os.environ). - printf '%s' "$one_line" | ZABBIX_SERVER="$ZABBIX_SERVER" ZABBIX_HOST_NAME="$ZABBIX_HOST_NAME" \ - SSHMON_ZBX_TRAPPER_K="$key_al" SSHMON_ZBX_TRAPPER_LOG="$zlog" python3 -c ' -import os, subprocess, sys, traceback -val = sys.stdin.read() -k = os.environ["SSHMON_ZBX_TRAPPER_K"] -path = os.environ["SSHMON_ZBX_TRAPPER_LOG"] -try: - r = subprocess.run( - ["zabbix_sender", "-z", os.environ["ZABBIX_SERVER"], - "-s", os.environ["ZABBIX_HOST_NAME"], "-k", k, "-o", val], - capture_output=True, text=True) - out = (r.stdout or "") + (r.stderr or "") -except Exception: - out = "ssh-monitor python zabbix_sender: " + traceback.format_exc() -with open(path, "w", encoding="utf-8", newline="\n") as fh: - fh.write(out) -' || true - if _sshmon_zbx_parse_sender_output "alert (${key_al})" "$zlog" 0; then - alert_rc=0 - fi - else - tmp="$(mktemp "${TMPDIR:-/tmp}/ssh-monitor.zbx.al.XXXXXX" 2>/dev/null)" || { - rm -f "$zlog" - return 1 - } - printf '%s\t%s\n' "$key_al" "$one_line" >"$tmp" || { - rm -f "$tmp" "$zlog" - return 1 - } - if _sshmon_zbx_send "alert (${key_al})" "$zlog" -i "$tmp"; then - alert_rc=0 - fi - rm -f "$tmp" - fi - - if _sshmon_zbx_send "seq (${key_sq})" "$zlog" -k "$key_sq" -o "${ZABBIX_EVENT_SEQ}"; then - seq_rc=0 - fi - - rm -f "$zlog" - _sshmon_zbx_save_seq_state - - # Раньше один файл с двумя строками: при failed по seq весь пакет считался неуспешным. - # Текст оповещения — основной результат; seq нужен для триггеров по изменению. - if [ "$alert_rc" -eq 0 ]; then - [ "$seq_rc" -ne 0 ] && write_log "WARN: в Zabbix ушёл только ${key_al}; проверьте item ${key_sq} (тип unsigned, trapper)" - return 0 - fi - return 1 -} - notify_send() { local message="$1" - local zabbix_plain="${2:-}" if [ "${DRY_RUN:-0}" = "1" ]; then printf '[DRY-RUN notify] %s\n' "$(printf '%s' "$message" | tr '\n' ' ')" >&2 - if [ -n "$zabbix_plain" ]; then - printf '[DRY-RUN notify] zabbix (plain): %s\n' "$(printf '%s' "$zabbix_plain" | tr '\n' ' ')" >&2 - fi return 0 fi @@ -816,13 +588,6 @@ notify_send() { write_log "WARN: канал telegram: не удалось отправить уведомление" fi ;; - zabbix) - if send_zabbix_trapper "${zabbix_plain:-$message}"; then - any_ok=1 - else - write_log "WARN: канал zabbix: не удалось отправить (zabbix_sender, сеть, имя хоста/ключи в Zabbix)" - fi - ;; email) if send_email_raw "$message"; then any_ok=1 @@ -1004,8 +769,7 @@ block_ip() { message="${message}📊 Неудачных попыток: ${attempts}"$'\n' message="${message}⏱️ Блокировка на: ${ban_time_hours} час(ов)"$'\n' message="${message}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')" - local zbx_ban="IP заблокирован: $(server_display_name_with_ip), адрес ${ip}, неудачных попыток ${attempts}, блок ${ban_time_hours} ч." - notify_send "$message" "$zbx_ban" + notify_send "$message" write_log "AUTO-BAN: IP $ip заблокирован на $ban_time_hours часов ($attempts попыток)" } @@ -1030,8 +794,7 @@ unblock_expired_ips() { local message=" 🔓 IP РАЗБЛОКИРОВАН "$'\n' message="${message}🌐 IP: ${ip}"$'\n' message="${message}🕐 Время разблокировки: $(notification_date '+%d.%m.%Y %H:%M:%S')" - local zbx_un="IP разблокирован: $(server_display_name_with_ip), адрес ${ip}" - notify_send "$message" "$zbx_un" + notify_send "$message" unblocked=$((unblocked + 1)) fi @@ -1202,15 +965,13 @@ monitor_ssh() { message="${message}👤 Пользователь: ${user}"$'\n' message="${message}🌐 IP адрес: ${ip:-local}"$'\n' message="${message}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')" - local zbx_ssh="Новый SSH-вход: $(server_display_name_with_ip), пользователь ${user}, адрес ${ip:-local}" - notify_send "$message" "$zbx_ssh" + notify_send "$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')" - local zbx_root="Новый SSH-вход под root: $(server_display_name_with_ip), адрес ${ip:-local}" - notify_send "$rmsg" "$zbx_root" + notify_send "$rmsg" fi fi elif echo "$line" | grep -q "Failed password"; then @@ -1239,8 +1000,7 @@ monitor_ssh() { message="${message}🌐 IP адрес: ${ip}"$'\n' message="${message}📊 Попытка ${current_attempts} из ${MAX_ATTEMPTS}"$'\n' message="${message}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')" - local zbx_fail="Неудачный SSH: $(server_display_name_with_ip), пользователь ${user}, адрес ${ip}, попытка ${current_attempts} из ${MAX_ATTEMPTS}" - notify_send "$message" "$zbx_fail" + notify_send "$message" fi write_log "SSH неудачная попытка: $user с IP $ip (попытка $current_attempts из $MAX_ATTEMPTS)" fi @@ -1355,15 +1115,7 @@ monitor_sudo() { message="${message}💻 Команда: ${sudo_cmd}"$'\n' message="${message}📁 Директория: ${pwd:-unknown}"$'\n' message="${message}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')" - local cmdz="${sudo_cmd}" - if [ "${#cmdz}" -gt 200 ]; then - cmdz="${cmdz:0:200}…" - fi - local zbx_sudo="Повышение привилегий (sudo): $(server_display_name_with_ip), пользователь ${user}, команда: ${cmdz}" - if [ -n "${run_as:-}" ]; then - zbx_sudo="${zbx_sudo}, от имени ${run_as}" - fi - notify_send "$message" "$zbx_sudo" + notify_send "$message" write_log "SUDO: $user выполнил $sudo_cmd${run_as:+ (USER=$run_as)}" fi done <<<"$sudo_logs" @@ -1404,8 +1156,7 @@ monitor_security_events() { local m=" 🔐 ВНИМАНИЕ: изменение SSH host key / MITM? "$'\n' m="${m}$(printf '%s\n' "$hk")" m="${m}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')" - local zbx_hk="SSH host key / MITM: $(server_display_name_with_ip), см. журнал" - notify_send "$m" "$zbx_hk" + notify_send "$m" write_log "SECURITY: обнаружено сообщение об изменении host key" fi fi @@ -1426,8 +1177,7 @@ monitor_security_events() { bm="${bm}🌐 IP: ${ip}"$'\n' bm="${bm}📊 Неудачных попыток за ~${BRUTE_WINDOW_SEC}s: ${count}"$'\n' bm="${bm}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')" - local zbx_br="SSH брутфорс: $(server_display_name_with_ip), IP ${ip}, попыток ~${count} за ${BRUTE_WINDOW_SEC}s" - notify_send "$bm" "$zbx_br" + notify_send "$bm" write_log "SECURITY: массовый брутфорс? IP=$ip fails=${count} за окно ${BRUTE_WINDOW_SEC}s" fi fi @@ -1538,8 +1288,7 @@ monitor_logind() { msg="${msg}👤 Пользователь: ${user:-неизвестно}"$'\n' msg="${msg}🆔 Сессия: ${sid:-?}"$'\n' msg="${msg}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')" - local zbx_ln="Сессия logind: $(server_display_name_with_ip), пользователь ${user:-?}, sid ${sid:-?}" - notify_send "$msg" "$zbx_ln" + notify_send "$msg" write_log "LOGIND: новая сессия: ${line}" else write_log "LOGIND: новая сессия (разбор user/sid не удался): ${line}" @@ -1556,8 +1305,7 @@ monitor_logind() { 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')" - local zbx_lr="Сессия закрыта (logind): $(server_display_name_with_ip), sid ${sess_r:-?}" - notify_send "$msg_r" "$zbx_lr" + notify_send "$msg_r" fi elif [[ "$low" == *"failed"* ]]; then @@ -1565,12 +1313,7 @@ monitor_logind() { local msg_f=" ❌ СБОЙ (systemd-logind) "$'\n' msg_f="${msg_f}📝 ${line}"$'\n' msg_f="${msg_f}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')" - local ln_f="$line" - if [ "${#ln_f}" -gt 180 ]; then - ln_f="${ln_f:0:180}…" - fi - local zbx_lf="Сбой logind: $(server_display_name_with_ip), ${ln_f}" - notify_send "$msg_f" "$zbx_lf" + notify_send "$msg_f" write_log "LOGIND: сбой: $line" fi fi @@ -1699,8 +1442,7 @@ send_daily_report() { message="${message} Нет активных пользователей"$'\n' fi - local zbx_rep="Отчет ssh-monitor 24h: $(server_display_name_with_ip), ok=${successful_logins:-0} fail=${failed_logins:-0} sudo=${sudo_commands:-0} bans=${active_bans}" - notify_send "$message" "$zbx_rep" + notify_send "$message" write_log "Ежедневный отчет отправлен" echo "$(report_date '+%F')" >"$LAST_REPORT_FILE" @@ -1759,8 +1501,7 @@ send_heartbeat() { message="${message}🖥️ Сервер: $(server_display_name_with_ip)"$'\n' message="${message}🕐 Время: ${timestamp_human}" - local zbx_hb="Heartbeat ssh-monitor: $(server_display_name_with_ip)" - notify_send "$message" "$zbx_hb" + notify_send "$message" echo "$timestamp" >"$LAST_HEARTBEAT_FILE" write_log "Heartbeat отправлен" } @@ -1824,8 +1565,7 @@ send_startup_notification() { message="${message}❤️ Heartbeat будет отправляться каждые 12 часов"$'\n' message="${message}📢 Каналы уведомлений (все по списку): $(notify_chain_human)" - local zbx_su="Старт ssh-monitor v${SSH_MONITOR_VERSION:-?}: $(server_display_name_with_ip), whitelist=${whitelist_count}" - notify_send "$message" "$zbx_su" + notify_send "$message" write_log "Скрипт мониторинга запущен (белый список: $whitelist_count записей)" } @@ -1839,8 +1579,7 @@ send_shutdown_notification() { message="${message}🖥️ Сервер: $(server_display_name_with_ip)"$'\n' message="${message}🕐 Время остановки: ${timestamp}" - local zbx_sd="Стоп ssh-monitor: $(server_display_name_with_ip)" - notify_send "$message" "$zbx_sd" + notify_send "$message" write_log "Скрипт мониторинга остановлен" } @@ -1898,16 +1637,6 @@ run_check_config() { 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 'ZABBIX_SERVER=%s ZABBIX_HOST_NAME=%s\n' "${ZABBIX_SERVER:-}" "${ZABBIX_HOST_NAME:-}" - printf 'ZABBIX_ALERT_KEY=%s ZABBIX_SEQ_KEY=%s ZABBIX_ALERT_MAX_BYTES=%s ZABBIX_SEQ_STATE_FILE=%s\n' \ - "${ZABBIX_ALERT_KEY:-}" "${ZABBIX_SEQ_KEY:-}" "${ZABBIX_ALERT_MAX_BYTES:-0}" "${ZABBIX_SEQ_STATE_FILE:-}" - if [ -n "${ZABBIX_SERVER:-}" ] && [ -n "${ZABBIX_HOST_NAME:-}" ]; then - if command -v zabbix_sender >/dev/null 2>&1; then - printf 'zabbix_sender: найден (%s)\n' "$(command -v zabbix_sender)" - else - printf 'zabbix_sender: НЕ найден в PATH\n' >&2 - fi - fi 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:-}" diff --git a/ssh-monitor.conf.example b/ssh-monitor.conf.example index d2cdf88..cd3263f 100644 --- a/ssh-monitor.conf.example +++ b/ssh-monitor.conf.example @@ -2,40 +2,29 @@ TELEGRAM_BOT_TOKEN="replace_with_telegram_bot_token" TELEGRAM_CHAT_ID="replace_with_telegram_chat_id" # Резервный webhook, если ВСЕ каналы из NOTIFY_CHAIN не смогли доставить сообщение (JSON {"text":"..."}). -# ВАЖНО: BACKUP_WEBHOOK_URL НЕ заменяет основной канал — без хотя бы одного из telegram/zabbix/email +# ВАЖНО: BACKUP_WEBHOOK_URL НЕ заменяет основной канал — без хотя бы одного из telegram/email # скрипт сразу завершится с ошибкой в stderr: «Не настроен ни один канал отправки оповещений» (код 1), -# в том числе при --check-config и --dry-run. Сначала настройте Telegram, Zabbix или SMTP ниже. +# в том числе при --check-config и --dry-run. Сначала настройте Telegram или SMTP ниже. BACKUP_WEBHOOK_URL="" # --- Очередь оповещений (NOTIFY_ORDER) --- -# CSV: telegram, zabbix, email (или сокращения tg, zbx, mail). +# CSV: telegram, email (или сокращения tg, mail). # При каждом событии скрипт ПЫТАЕТСЯ ОТПРАВИТЬ ВО ВСЕ перечисленные каналы по порядку (не только в первый успешный). # Неудача одного канала не отменяет отправку в остальные; подробности — в LOG_FILE (WARN по каналам). # # Пустая строка NOTIFY_ORDER="" = авто: в цепочку NOTIFY_CHAIN попадают ТОЛЬКО реально настроенные каналы, -# порядок по умолчанию: telegram → zabbix → email (см. критерии «настроен» в README, раздел про каналы). +# порядок по умолчанию: telegram → email (см. критерии «настроен» в README, раздел про каналы). # # Если после сборки цепочки каналов список ПУСТ (ни один канал не настроен, либо в NOTIFY_ORDER только # неизвестные токены), скрипт НЕ запускает мониторинг и выходит с ошибкой: # Не настроен ни один канал отправки оповещений # # Примеры явной очереди: -# NOTIFY_ORDER="telegram,zabbix,email" +# NOTIFY_ORDER="telegram,email" # NOTIFY_ORDER="telegram" -# NOTIFY_ORDER="zabbix" # NOTIFY_ORDER="email" NOTIFY_ORDER="" -# Zabbix trapper (zabbix_sender). Нужны ZABBIX_SERVER и ZABBIX_HOST_NAME. -ZABBIX_SERVER="" -ZABBIX_HOST_NAME="" -ZABBIX_ALERT_KEY="ssh.monitor.alert" -ZABBIX_SEQ_KEY="ssh.monitor.seq" -# В Zabbix уходит короткий plain-текст (второй аргумент notify_send); 0 = не урезать длину. -ZABBIX_ALERT_MAX_BYTES="0" -# Последний ssh.monitor.seq на диске — после перезапуска счётчик не сбрасывается. -ZABBIX_SEQ_STATE_FILE="/var/log/ssh_monitor_zabbix_seq" - # SMTP (python3). Для авто-режима почты нужны MAIL_SMTP_HOST, MAIL_FROM, MAIL_TO. MAIL_SMTP_HOST="" MAIL_SMTP_PORT="587" @@ -67,7 +56,7 @@ DAILY_REPORT_HOUR="9" # IANA-зона для «9:00» и календарной даты отчёта; пусто = как у команды date (обычно локаль сервера, см. timedatectl) DAILY_REPORT_TZ="" DAILY_REPORT_TOP_IPS="5" -# IANA-зона для «🕐 Время» в Telegram/Zabbix/email. Пусто = как DAILY_REPORT_TZ, иначе зона процесса (часто UTC в systemd — см. README). +# IANA-зона для «🕐 Время» в Telegram/email. Пусто = как DAILY_REPORT_TZ, иначе зона процесса (часто UTC в systemd — см. README). NOTIFY_TZ="" # Не чаще 1 успешного SSH (Accepted) в Telegram на пару user+IP за N секунд (разные port в журнале). 0 = только дедуп внутри одного прохода. SSH_ACCEPT_NOTIFY_DEDUP_SEC="5"