Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 673145a199 | |||
| 1a8352d65c | |||
| d43afe03c6 | |||
| ce6a3d4620 | |||
| 25f85d00fb | |||
| 6a4db2d7ad | |||
| 6552b0225f | |||
| 5e6b0dedb7 | |||
| 0e0333e7e4 | |||
| eee84e5eb7 | |||
| 921fc9c795 | |||
| 57935e292e | |||
| 4975a6d842 | |||
| 80d2508d52 | |||
| 415da821de | |||
| 02fa70722c | |||
| da5d2638d7 | |||
| f52dda23ad | |||
| 17737cbf09 | |||
| a1bfa6a003 | |||
| bdb10e82d8 | |||
| 0f2b26b4b4 | |||
| febe359248 | |||
| ced61c417a | |||
| 3cfdc7b12c |
@@ -1,2 +1,4 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
CalculatorApp/
|
CalculatorApp/
|
||||||
|
# Артефакты локальной сборки (релизные архивы грузятся в GitHub Releases, в git не коммитим)
|
||||||
|
ssh-monitor-*.tar.gz
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Версия берётся из ssh-monitor (SSH_MONITOR_VERSION).
|
||||||
|
.PHONY: dist clean-dist
|
||||||
|
VERSION := $(shell sed -n 's/^SSH_MONITOR_VERSION="\(.*\)".*/\1/p' ssh-monitor | head -n1)
|
||||||
|
DIST := ssh-monitor-$(VERSION).tar.gz
|
||||||
|
|
||||||
|
dist: $(DIST)
|
||||||
|
|
||||||
|
$(DIST): ssh-monitor
|
||||||
|
git archive --format=tar.gz --prefix=ssh-monitor-$(VERSION)/ -o $(DIST) HEAD
|
||||||
|
|
||||||
|
clean-dist:
|
||||||
|
rm -f ssh-monitor-*.tar.gz
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# ssh-monitor
|
# ssh-monitor
|
||||||
|
|
||||||
Bash-скрипт мониторинга SSH/SUDO с уведомлениями в Telegram, опциональным резервным webhook, авто-блокировкой IP (IPv4 через `iptables`, IPv6 через `ip6tables` при наличии) и ежедневным отчётом.
|
Bash-скрипт мониторинга **SSH**, **SUDO** и событий **`systemd-logind`** (локальные/графические сессии и др.) с уведомлениями в **Telegram** и по **электронной почте (SMTP)**, опциональным резервным webhook, авто-блокировкой IP (IPv4 через `iptables`, IPv6 через `ip6tables` при наличии) и ежедневным отчётом.
|
||||||
|
|
||||||
## Конфигурация
|
## Конфигурация
|
||||||
|
|
||||||
@@ -10,16 +10,21 @@ Bash-скрипт мониторинга SSH/SUDO с уведомлениями
|
|||||||
- `sudo cp ./ssh-monitor.conf.example /etc/ssh-monitor.conf`
|
- `sudo cp ./ssh-monitor.conf.example /etc/ssh-monitor.conf`
|
||||||
2. Ограничьте доступ:
|
2. Ограничьте доступ:
|
||||||
- `sudo chmod 600 /etc/ssh-monitor.conf`
|
- `sudo chmod 600 /etc/ssh-monitor.conf`
|
||||||
3. Заполните минимум:
|
3. Заполните минимум — **хотя бы один канал доставки оповещений** (иначе скрипт сразу завершится с ошибкой; см. абзац **«Обязательное условие»** ниже и раздел **«Каналы оповещений и пустой NOTIFY_CHAIN»**):
|
||||||
- `TELEGRAM_BOT_TOKEN`
|
- для **Telegram**: `TELEGRAM_BOT_TOKEN` и `TELEGRAM_CHAT_ID`;
|
||||||
- `TELEGRAM_CHAT_ID`
|
- или настройте **почту (SMTP)** по переменным ниже при `NOTIFY_ORDER=""` (автовыбор каналов).
|
||||||
|
|
||||||
|
**Обязательное условие:** после загрузки конфигурации в цепочке **`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`
|
- `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`
|
||||||
- `BACKUP_WEBHOOK_URL` — резервная доставка JSON `{"text":"..."}` (например Slack Incoming Webhook), если Telegram недоступен или вернул ошибку
|
- `NOTIFY_ORDER` — список каналов (`telegram`, `email` и сокращения **tg**, **mail**); при каждом оповещении скрипт **пытается отправить во все перечисленные** каналы по порядку (не «первый успешный — и стоп»). Пусто = в цепочку попадают только настроенные каналы (порядок по умолчанию см. в `ssh-monitor.conf.example`). Если итоговая цепочка пуста — скрипт не стартует (см. раздел **«Каналы оповещений и пустой NOTIFY_CHAIN»**).
|
||||||
- `LOG_FILE`, `LAST_HEARTBEAT_FILE`, `LAST_REPORT_FILE`, `LAST_SSH_CHECK_FILE`, `LAST_SUDO_CHECK_FILE`, `LAST_SECURITY_EVENTS_FILE`, `BAN_LIST_FILE`
|
- `MAIL_SMTP_HOST`, `MAIL_SMTP_PORT`, `MAIL_SMTP_USER`, `MAIL_SMTP_PASSWORD`, `MAIL_FROM`, `MAIL_TO`, `MAIL_SMTP_STARTTLS`, `MAIL_SMTP_SSL` — отправка почты через **python3** (см. таблицу каналов ниже)
|
||||||
- `DAILY_REPORT_HOUR` (0..23), `DAILY_REPORT_TZ` (опционально), `DAILY_REPORT_TOP_IPS`
|
- `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» ниже
|
||||||
|
- `DAILY_REPORT_HOUR` (0..23), `DAILY_REPORT_TZ` (опционально), `NOTIFY_TZ` (опционально), `SSH_ACCEPT_NOTIFY_DEDUP_SEC`, `DAILY_REPORT_TOP_IPS`
|
||||||
- `BRUTE_WINDOW_SEC`, `BRUTE_MIN_FAILS`, `BRUTE_NOTIFY_COOLDOWN_SEC`
|
- `BRUTE_WINDOW_SEC`, `BRUTE_MIN_FAILS`, `BRUTE_NOTIFY_COOLDOWN_SEC`
|
||||||
- `PROMETHEUS_TEXTFILE_DIR` — каталог для `ssh_monitor.prom` (совместимость с node_exporter textfile collector)
|
- `PROMETHEUS_TEXTFILE_DIR` — каталог для `ssh_monitor.prom` (совместимость с node_exporter textfile collector)
|
||||||
- `HEALTHCHECK_STATUS_FILE` — путь к JSON-файлу с меткой последней итерации цикла
|
- `HEALTHCHECK_STATUS_FILE` — путь к JSON-файлу с меткой последней итерации цикла
|
||||||
@@ -33,16 +38,26 @@ Bash-скрипт мониторинга SSH/SUDO с уведомлениями
|
|||||||
|
|
||||||
- `TELEGRAM_BOT_TOKEN` — токен Telegram-бота для отправки уведомлений.
|
- `TELEGRAM_BOT_TOKEN` — токен Telegram-бота для отправки уведомлений.
|
||||||
- `TELEGRAM_CHAT_ID` — ID чата/пользователя, куда отправляются уведомления.
|
- `TELEGRAM_CHAT_ID` — ID чата/пользователя, куда отправляются уведомления.
|
||||||
- `BACKUP_WEBHOOK_URL` — URL для резервной отправки (тело JSON `{"text":"..."}`).
|
- `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` — путь к основному лог-файлу скрипта.
|
- `LOG_FILE` — путь к основному лог-файлу скрипта.
|
||||||
- `LAST_HEARTBEAT_FILE` — файл с timestamp последнего heartbeat-сообщения.
|
- `LAST_HEARTBEAT_FILE` — файл с timestamp последнего heartbeat-сообщения.
|
||||||
- `LAST_REPORT_FILE` — дата последнего ежедневного отчёта в формате `YYYY-MM-DD` (старый формат Unix-времени при первом запуске будет автоматически интерпретирован).
|
- `LAST_REPORT_FILE` — дата последнего ежедневного отчёта в формате `YYYY-MM-DD` (старый формат Unix-времени при первом запуске будет автоматически интерпретирован).
|
||||||
- `LAST_SSH_CHECK_FILE` — файл с меткой времени последней проверки SSH-событий.
|
- `LAST_SSH_CHECK_FILE` — файл с меткой времени последней проверки SSH-событий.
|
||||||
- `LAST_SUDO_CHECK_FILE` — файл с меткой времени последней проверки sudo-событий.
|
- `LAST_SUDO_CHECK_FILE` — файл с меткой времени последней проверки sudo-событий.
|
||||||
- `LAST_SECURITY_EVENTS_FILE` — метка последней проверки «тяжёлых» событий безопасности в журнале.
|
- `LAST_SECURITY_EVENTS_FILE` — метка последней проверки «тяжёлых» событий безопасности в журнале.
|
||||||
|
- `LAST_LOGIND_CHECK_FILE` — метка последней обработки журнала `systemd-logind` (unix-время); при первом запуске подтягивается около 30 минут истории.
|
||||||
- `BAN_LIST_FILE` — файл состояния банов (IP, время окончания бана и метаданные).
|
- `BAN_LIST_FILE` — файл состояния банов (IP, время окончания бана и метаданные).
|
||||||
|
- `ENABLE_LOGIND_MONITOR` — `1` включает опрос `journalctl -u systemd-logind`, `0` полностью отключает этот блок.
|
||||||
|
- `LOGIND_NOTIFY_NEW` — `1` отправлять Telegram при появлении новой сессии в logind (строки вида *New session … of user …*).
|
||||||
|
- `LOGIND_NOTIFY_REMOVED` — `1` отправлять Telegram при завершении сессии (*Removed session …*); по умолчанию `0` (только запись в `LOG_FILE`, чтобы не заспамить канал).
|
||||||
|
- `LOGIND_NOTIFY_FAILED` — `1` отправлять Telegram по строкам logind, содержащим *failed* (широкий фильтр; при необходимости отключите).
|
||||||
|
- `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_HOUR` — час (0..23), после наступления которого в **текущих календарных сутках** (в выбранной ниже зоне) отправляется не более одного ежедневного отчёта.
|
||||||
- `DAILY_REPORT_TZ` — необязательная **IANA**-зона (`Europe/Moscow`, `Asia/Yekaterinburg`, …). Если **пусто**, для отчёта используется та же зона, что и у команды `date` у процесса монитора (как правило, совпадает с `timedatectl` / `/etc/localtime` на сервере). Если сервис запускается с `TZ=UTC` в unit-файле, без `DAILY_REPORT_TZ` отчёт ориентируется на **UTC** — тогда задайте явную зону в конфиге.
|
- `DAILY_REPORT_TZ` — необязательная **IANA**-зона (`Europe/Moscow`, `Asia/Yekaterinburg`, …). Если **пусто**, для отчёта используется та же зона, что и у команды `date` у процесса монитора (как правило, совпадает с `timedatectl` / `/etc/localtime` на сервере). Если сервис запускается с `TZ=UTC` в unit-файле, без `DAILY_REPORT_TZ` отчёт ориентируется на **UTC** — тогда задайте явную зону в конфиге.
|
||||||
|
- `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 часа.
|
- `DAILY_REPORT_TOP_IPS` — сколько IP показывать в топе неудачных попыток за 24 часа.
|
||||||
- `BRUTE_WINDOW_SEC` — окно (секунды) для оценки «массового» брутфорса по `journalctl`.
|
- `BRUTE_WINDOW_SEC` — окно (секунды) для оценки «массового» брутфорса по `journalctl`.
|
||||||
- `BRUTE_MIN_FAILS` — минимум неудачных попыток за окно для тревоги.
|
- `BRUTE_MIN_FAILS` — минимум неудачных попыток за окно для тревоги.
|
||||||
@@ -60,6 +75,27 @@ Bash-скрипт мониторинга SSH/SUDO с уведомлениями
|
|||||||
- `WATCHDOG_SERVICE_NAME` — имя systemd-сервиса, который контролирует watchdog (по умолчанию `ssh-monitor.service`).
|
- `WATCHDOG_SERVICE_NAME` — имя systemd-сервиса, который контролирует watchdog (по умолчанию `ssh-monitor.service`).
|
||||||
- `WATCHDOG_NOTIFY_ON_RECOVERY` — `1` включает служебные сообщения watchdog при штатном состоянии, `0` отключает.
|
- `WATCHDOG_NOTIFY_ON_RECOVERY` — `1` включает служебные сообщения watchdog при штатном состоянии, `0` отключает.
|
||||||
|
|
||||||
|
### Каналы оповещений и пустой NOTIFY_CHAIN
|
||||||
|
|
||||||
|
Кратко, что считается **настроенным** каналом (только такие попадают в автоматическую цепочку при **`NOTIFY_ORDER=""`**):
|
||||||
|
|
||||||
|
| Канал | Условие «настроен» |
|
||||||
|
|--------|-------------------|
|
||||||
|
| `telegram` | заданы **`TELEGRAM_BOT_TOKEN`** и **`TELEGRAM_CHAT_ID`** |
|
||||||
|
| `email` | непустые **`MAIL_SMTP_HOST`**, **`MAIL_FROM`**, **`MAIL_TO`**, в PATH есть **`python3`** |
|
||||||
|
|
||||||
|
Если итоговый **`NOTIFY_CHAIN` пуст**, скрипт завершается с сообщением **`Не настроен ни один канал отправки оповещений`** (см. шаг 3 в разделе «Конфигурация» выше).
|
||||||
|
|
||||||
|
### Мониторинг systemd-logind
|
||||||
|
|
||||||
|
- Собираются сообщения юнита **`systemd-logind`** через **`journalctl`** (`-o cat`): новые и завершённые сессии, а также строки с подстрокой *failed* (если включено).
|
||||||
|
- Без **`journalctl`** на хосте этот блок **не работает** (как и часть других функций, завязанных на journal).
|
||||||
|
- Для разбора «новой сессии» используются типичные англоязычные форматы (`New session … of user …`). При несовпадении формата строка всё равно попадёт в лог с пометкой о неудачном разборе.
|
||||||
|
- **`loginctl`** нужен для **`LOGIND_SKIP_REMOTE`**: по `Type` и **`Service`** решается, не дублировать ли Telegram с **`monitor_ssh`** (см. описание `LOGIND_SKIP_REMOTE` выше).
|
||||||
|
- **`monitor_ssh`**: события **sshd** читаются из journal преимущественно как **`journalctl _COMM=sshd`** (запасные варианты — юниты `sshd` / `ssh`), строки **`sort -u`**, для **`Accepted`** в одном проходе — дедуп по **пользователь|IP|порт**; между проходами цикла — пауза **`SSH_ACCEPT_NOTIFY_DEDUP_SEC`** для той же пары **пользователь|IP** (см. переменную в конфиге).
|
||||||
|
|
||||||
|
Команда **`ssh-monitor --check-config`** выводит актуальные значения, в том числе параметры logind.
|
||||||
|
|
||||||
## Режимы запуска
|
## Режимы запуска
|
||||||
|
|
||||||
Обычный запуск (root):
|
Обычный запуск (root):
|
||||||
@@ -86,6 +122,16 @@ sudo bash ./ssh-monitor --dry-run
|
|||||||
bash -n ./ssh-monitor
|
bash -n ./ssh-monitor
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Релизный архив
|
||||||
|
|
||||||
|
Версия задаётся в скрипте переменной **`SSH_MONITOR_VERSION`** (текущая стабильная — **1.0.0**). Сборка tarball из текущего git-дерева:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dist
|
||||||
|
```
|
||||||
|
|
||||||
|
Появится файл `ssh-monitor-<версия>.tar.gz` (через `git archive`). Готовые архивы для установки без клона репозитория прикладываются к [релизам на GitHub](https://github.com/PTah/ssh-monitor/releases).
|
||||||
|
|
||||||
## Автозапуск через systemd
|
## Автозапуск через systemd
|
||||||
|
|
||||||
1. Скопируйте скрипт в постоянное место:
|
1. Скопируйте скрипт в постоянное место:
|
||||||
@@ -129,12 +175,14 @@ sudo journalctl -u ssh-monitor -f
|
|||||||
|
|
||||||
## Ротация логов
|
## Ротация логов
|
||||||
|
|
||||||
Пример для `logrotate` лежит в репозитории: `contrib/logrotate.d/ssh-monitor`. Скопируйте файл в `/etc/logrotate.d/` и при необходимости поправьте пути под ваши `LOG_FILE` / `WATCHDOG_LOG_FILE`.
|
Пример для `logrotate` лежит в репозитории: `contrib/logrotate.d/ssh-monitor`. Скопируйте файл в `/etc/logrotate.d/` и при необходимости поправьте пути под ваши `LOG_FILE` / `WATCHDOG_LOG_FILE` и другие файлы состояния в `/var/log/` (в т.ч. `LAST_*`, если решите их ротировать отдельно).
|
||||||
|
|
||||||
## Статистика без systemd (`journalctl`)
|
## Статистика без systemd (`journalctl`)
|
||||||
|
|
||||||
Для корректного подсчёта событий за последние 24 часа по файлам `/var/log/auth.log*` рекомендуется наличие **Python 3** на сервере. При его отсутствии счётчики в ежедневном отчёте для «классического» syslog могут быть занижены (используется безопасный fallback).
|
Для корректного подсчёта событий за последние 24 часа по файлам `/var/log/auth.log*` рекомендуется наличие **Python 3** на сервере. При его отсутствии счётчики в ежедневном отчёте для «классического» syslog могут быть занижены (используется безопасный fallback).
|
||||||
|
|
||||||
|
**Мониторинг `systemd-logind`** и другие части, использующие **`journalctl`**, на таких системах **не выполняются** или дают неполные данные — ориентируйтесь на окружение с **systemd** и доступным журналом.
|
||||||
|
|
||||||
## Watchdog (автоматическое восстановление)
|
## Watchdog (автоматическое восстановление)
|
||||||
|
|
||||||
Watchdog проверяет:
|
Watchdog проверяет:
|
||||||
|
|||||||
+462
-28
@@ -7,11 +7,26 @@ IFS=$'\n\t'
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
CONFIG_FILE="/etc/ssh-monitor.conf"
|
CONFIG_FILE="/etc/ssh-monitor.conf"
|
||||||
|
SSH_MONITOR_VERSION="1.0.0"
|
||||||
|
|
||||||
TELEGRAM_BOT_TOKEN=""
|
TELEGRAM_BOT_TOKEN=""
|
||||||
TELEGRAM_CHAT_ID=""
|
TELEGRAM_CHAT_ID=""
|
||||||
BACKUP_WEBHOOK_URL=""
|
BACKUP_WEBHOOK_URL=""
|
||||||
|
|
||||||
|
# Очередь оповещений: CSV из имён telegram, email (или tg, mail).
|
||||||
|
# Пусто = авто: только настроенные каналы, порядок telegram → email.
|
||||||
|
NOTIFY_ORDER=""
|
||||||
|
|
||||||
|
# Почта (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"
|
LOG_FILE="/var/log/ssh_monitor.log"
|
||||||
LAST_HEARTBEAT_FILE="/var/log/last_heartbeat.txt"
|
LAST_HEARTBEAT_FILE="/var/log/last_heartbeat.txt"
|
||||||
@@ -21,11 +36,24 @@ DAILY_REPORT_TOP_IPS=5
|
|||||||
# Пусто = календарь и час отчёта в зоне процесса (как `date`, обычно совпадает с /etc/localtime).
|
# Пусто = календарь и час отчёта в зоне процесса (как `date`, обычно совпадает с /etc/localtime).
|
||||||
# Иначе IANA, например Europe/Moscow — явная зона для DAILY_REPORT_HOUR и даты «сегодня» в отчёте.
|
# Иначе IANA, например Europe/Moscow — явная зона для DAILY_REPORT_HOUR и даты «сегодня» в отчёте.
|
||||||
DAILY_REPORT_TZ=""
|
DAILY_REPORT_TZ=""
|
||||||
|
# IANA-зона для строк «🕐 Время» в Telegram/email. Пусто = тогда DAILY_REPORT_TZ, если она задана, иначе зона процесса (часто UTC у systemd — см. README).
|
||||||
|
NOTIFY_TZ=""
|
||||||
|
# Не слать повтор «Accepted» для той же пары user+IP чаще, чем раз в N секунд (разные port в журнале — один вход).
|
||||||
|
SSH_ACCEPT_NOTIFY_DEDUP_SEC=5
|
||||||
|
|
||||||
# Временные файлы для отслеживания уже обработанных событий
|
# Временные файлы для отслеживания уже обработанных событий
|
||||||
LAST_SSH_CHECK_FILE="/var/log/last_ssh_check"
|
LAST_SSH_CHECK_FILE="/var/log/last_ssh_check"
|
||||||
LAST_SUDO_CHECK_FILE="/var/log/last_sudo_check"
|
LAST_SUDO_CHECK_FILE="/var/log/last_sudo_check"
|
||||||
LAST_SECURITY_EVENTS_FILE="/var/log/last_security_events_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
|
# Эксплуатация / health
|
||||||
PROMETHEUS_TEXTFILE_DIR=""
|
PROMETHEUS_TEXTFILE_DIR=""
|
||||||
@@ -55,11 +83,13 @@ WHITELIST_SUBNETS=(
|
|||||||
|
|
||||||
declare -A FAIL_COUNTS
|
declare -A FAIL_COUNTS
|
||||||
declare -A BRUTE_LAST_NOTIFY
|
declare -A BRUTE_LAST_NOTIFY
|
||||||
|
declare -A SSH_ACCEPT_LAST_NOTIFY
|
||||||
|
|
||||||
CHECK_CONFIG_ONLY=0
|
CHECK_CONFIG_ONLY=0
|
||||||
DRY_RUN=0
|
DRY_RUN=0
|
||||||
# Защита от двойного вызова: при SIGTERM срабатывает trap TERM, затем при выходе — trap EXIT
|
# Защита от двойного вызова: при SIGTERM срабатывает trap TERM, затем при выходе — trap EXIT
|
||||||
SHUTDOWN_NOTIFIED=0
|
SHUTDOWN_NOTIFIED=0
|
||||||
|
declare -a NOTIFY_CHAIN=()
|
||||||
|
|
||||||
trim_value() {
|
trim_value() {
|
||||||
local value="$1"
|
local value="$1"
|
||||||
@@ -122,9 +152,17 @@ load_config() {
|
|||||||
local cfg_daily_report_hour="${DAILY_REPORT_HOUR}"
|
local cfg_daily_report_hour="${DAILY_REPORT_HOUR}"
|
||||||
local cfg_daily_report_top_ips="${DAILY_REPORT_TOP_IPS}"
|
local cfg_daily_report_top_ips="${DAILY_REPORT_TOP_IPS}"
|
||||||
local cfg_daily_report_tz="${DAILY_REPORT_TZ}"
|
local cfg_daily_report_tz="${DAILY_REPORT_TZ}"
|
||||||
|
local cfg_notify_tz="${NOTIFY_TZ:-}"
|
||||||
|
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_ssh_check_file="${LAST_SSH_CHECK_FILE}"
|
||||||
local cfg_last_sudo_check_file="${LAST_SUDO_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_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_prometheus_textfile_dir="${PROMETHEUS_TEXTFILE_DIR}"
|
||||||
local cfg_healthcheck_status_file="${HEALTHCHECK_STATUS_FILE}"
|
local cfg_healthcheck_status_file="${HEALTHCHECK_STATUS_FILE}"
|
||||||
local cfg_brute_window_sec="${BRUTE_WINDOW_SEC}"
|
local cfg_brute_window_sec="${BRUTE_WINDOW_SEC}"
|
||||||
@@ -137,6 +175,15 @@ load_config() {
|
|||||||
local cfg_ban_list_file="${BAN_LIST_FILE}"
|
local cfg_ban_list_file="${BAN_LIST_FILE}"
|
||||||
local cfg_whitelist_ips_csv="${default_whitelist_ips_csv}"
|
local cfg_whitelist_ips_csv="${default_whitelist_ips_csv}"
|
||||||
local cfg_whitelist_subnets_csv="${default_whitelist_subnets_csv}"
|
local cfg_whitelist_subnets_csv="${default_whitelist_subnets_csv}"
|
||||||
|
local cfg_notify_order="${NOTIFY_ORDER:-}"
|
||||||
|
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
|
if [ -f "$CONFIG_FILE" ]; then
|
||||||
# shellcheck disable=SC1090
|
# shellcheck disable=SC1090
|
||||||
@@ -154,6 +201,12 @@ load_config() {
|
|||||||
LAST_SSH_CHECK_FILE="${LAST_SSH_CHECK_FILE:-$cfg_last_ssh_check_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_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_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}"
|
PROMETHEUS_TEXTFILE_DIR="${PROMETHEUS_TEXTFILE_DIR:-$cfg_prometheus_textfile_dir}"
|
||||||
HEALTHCHECK_STATUS_FILE="${HEALTHCHECK_STATUS_FILE:-$cfg_healthcheck_status_file}"
|
HEALTHCHECK_STATUS_FILE="${HEALTHCHECK_STATUS_FILE:-$cfg_healthcheck_status_file}"
|
||||||
BAN_LIST_FILE="${BAN_LIST_FILE:-$cfg_ban_list_file}"
|
BAN_LIST_FILE="${BAN_LIST_FILE:-$cfg_ban_list_file}"
|
||||||
@@ -161,6 +214,11 @@ load_config() {
|
|||||||
DAILY_REPORT_HOUR="${DAILY_REPORT_HOUR:-$cfg_daily_report_hour}"
|
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_TOP_IPS="${DAILY_REPORT_TOP_IPS:-$cfg_daily_report_top_ips}"
|
||||||
DAILY_REPORT_TZ="$(trim_value "${DAILY_REPORT_TZ:-$cfg_daily_report_tz}")"
|
DAILY_REPORT_TZ="$(trim_value "${DAILY_REPORT_TZ:-$cfg_daily_report_tz}")"
|
||||||
|
NOTIFY_TZ="$(trim_value "${NOTIFY_TZ:-$cfg_notify_tz}")"
|
||||||
|
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_WINDOW_SEC="${BRUTE_WINDOW_SEC:-$cfg_brute_window_sec}"
|
||||||
BRUTE_MIN_FAILS="${BRUTE_MIN_FAILS:-$cfg_brute_min_fails}"
|
BRUTE_MIN_FAILS="${BRUTE_MIN_FAILS:-$cfg_brute_min_fails}"
|
||||||
BRUTE_NOTIFY_COOLDOWN_SEC="${BRUTE_NOTIFY_COOLDOWN_SEC:-$cfg_brute_notify_cooldown_sec}"
|
BRUTE_NOTIFY_COOLDOWN_SEC="${BRUTE_NOTIFY_COOLDOWN_SEC:-$cfg_brute_notify_cooldown_sec}"
|
||||||
@@ -173,6 +231,16 @@ load_config() {
|
|||||||
local whitelist_subnets_csv="${WHITELIST_SUBNETS:-$cfg_whitelist_subnets_csv}"
|
local whitelist_subnets_csv="${WHITELIST_SUBNETS:-$cfg_whitelist_subnets_csv}"
|
||||||
csv_to_array "$whitelist_ips_csv" WHITELIST_IPS
|
csv_to_array "$whitelist_ips_csv" WHITELIST_IPS
|
||||||
csv_to_array "$whitelist_subnets_csv" WHITELIST_SUBNETS
|
csv_to_array "$whitelist_subnets_csv" WHITELIST_SUBNETS
|
||||||
|
|
||||||
|
NOTIFY_ORDER="$(trim_value "${NOTIFY_ORDER:-$cfg_notify_order}")"
|
||||||
|
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}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Дата/время для ежедневного отчёта (час DAILY_REPORT_HOUR и «сегодня» в LAST_REPORT_FILE).
|
# Дата/время для ежедневного отчёта (час DAILY_REPORT_HOUR и «сегодня» в LAST_REPORT_FILE).
|
||||||
@@ -184,6 +252,65 @@ report_date() {
|
|||||||
fi
|
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_config() {
|
||||||
validate_report_hour
|
validate_report_hour
|
||||||
validate_numeric_or_default "BAN_TIME" "3600"
|
validate_numeric_or_default "BAN_TIME" "3600"
|
||||||
@@ -194,6 +321,12 @@ validate_config() {
|
|||||||
validate_numeric_or_default "BRUTE_WINDOW_SEC" "900"
|
validate_numeric_or_default "BRUTE_WINDOW_SEC" "900"
|
||||||
validate_numeric_or_default "BRUTE_MIN_FAILS" "30"
|
validate_numeric_or_default "BRUTE_MIN_FAILS" "30"
|
||||||
validate_numeric_or_default "BRUTE_NOTIFY_COOLDOWN_SEC" "3600"
|
validate_numeric_or_default "BRUTE_NOTIFY_COOLDOWN_SEC" "3600"
|
||||||
|
validate_numeric_or_default "MAIL_SMTP_PORT" "587"
|
||||||
|
refresh_notify_chain
|
||||||
|
if [ "${#NOTIFY_CHAIN[@]}" -eq 0 ]; then
|
||||||
|
printf '%s\n' 'Не настроен ни один канал отправки оповещений' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -232,17 +365,15 @@ accepted = failed = sudo_cmd = 0
|
|||||||
failed_by_ip = collections.Counter()
|
failed_by_ip = collections.Counter()
|
||||||
|
|
||||||
line_re = re.compile(
|
line_re = re.compile(
|
||||||
r"^(?P<mon>[A-Za-z]{3})\s+(?P<day>\d{1,2})\s+(?P<hms>\d{2}:\d{2}:\d{2})\s+"
|
r"^(?P [A-Za-z]{3})\s+(?P \d{1,2})\s+(?P \d{2}:\d{2}:\d{2})\s+"
|
||||||
r"(?P<rest>.+)$"
|
r"(?P.+)$"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def open_any(path):
|
def open_any(path):
|
||||||
if str(path).endswith(".gz"):
|
if str(path).endswith(".gz"):
|
||||||
return gzip.open(path, "rt", errors="ignore")
|
return gzip.open(path, "rt", errors="ignore")
|
||||||
return path.open("r", errors="ignore")
|
return path.open("r", errors="ignore")
|
||||||
|
|
||||||
|
|
||||||
def parse_ts(mon: str, day: int, hms: str):
|
def parse_ts(mon: str, day: int, hms: str):
|
||||||
m = MONTHS.get(mon)
|
m = MONTHS.get(mon)
|
||||||
if not m:
|
if not m:
|
||||||
@@ -259,7 +390,6 @@ def parse_ts(mon: str, day: int, hms: str):
|
|||||||
dt = dt.replace(year=year + 1)
|
dt = dt.replace(year=year + 1)
|
||||||
return dt
|
return dt
|
||||||
|
|
||||||
|
|
||||||
ip_from_failed = re.compile(r" from ([^ ]+) port ")
|
ip_from_failed = re.compile(r" from ([^ ]+) port ")
|
||||||
ip_from_accepted = re.compile(r" from ([^ ]+) port ")
|
ip_from_accepted = re.compile(r" from ([^ ]+) port ")
|
||||||
|
|
||||||
@@ -317,9 +447,86 @@ write_log() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# УВЕДОМЛЕНИЯ: Telegram + резервный webhook
|
# УВЕДОМЛЕНИЯ: 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() {
|
send_telegram_raw() {
|
||||||
local message="$1"
|
local message="$1"
|
||||||
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
|
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
|
||||||
@@ -369,14 +576,38 @@ notify_send() {
|
|||||||
printf '[DRY-RUN notify] %s\n' "$(printf '%s' "$message" | tr '\n' ' ')" >&2
|
printf '[DRY-RUN notify] %s\n' "$(printf '%s' "$message" | tr '\n' ' ')" >&2
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
local any_ok=0
|
||||||
|
local ch
|
||||||
|
for ch in "${NOTIFY_CHAIN[@]}"; do
|
||||||
|
case "$ch" in
|
||||||
|
telegram)
|
||||||
if send_telegram_raw "$message"; then
|
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
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if send_backup_webhook_raw "$message"; then
|
if send_backup_webhook_raw "$message"; then
|
||||||
write_log "WARN: Telegram недоступен, сообщение ушло в BACKUP_WEBHOOK_URL"
|
write_log "WARN: все каналы из NOTIFY_CHAIN недоступны, сообщение ушло в BACKUP_WEBHOOK_URL"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
write_log "WARN: не удалось отправить уведомление (Telegram и резервный канал)"
|
|
||||||
|
write_log "WARN: не удалось доставить уведомление ни в один канал ($(notify_chain_human); BACKUP_WEBHOOK не задан или недоступен)"
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,7 +768,7 @@ block_ip() {
|
|||||||
message="${message}🌐 IP: ${ip}"$'\n'
|
message="${message}🌐 IP: ${ip}"$'\n'
|
||||||
message="${message}📊 Неудачных попыток: ${attempts}"$'\n'
|
message="${message}📊 Неудачных попыток: ${attempts}"$'\n'
|
||||||
message="${message}⏱️ Блокировка на: ${ban_time_hours} час(ов)"$'\n'
|
message="${message}⏱️ Блокировка на: ${ban_time_hours} час(ов)"$'\n'
|
||||||
message="${message}🕐 Время: $(date '+%d.%m.%Y %H:%M:%S')"
|
message="${message}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')"
|
||||||
notify_send "$message"
|
notify_send "$message"
|
||||||
write_log "AUTO-BAN: IP $ip заблокирован на $ban_time_hours часов ($attempts попыток)"
|
write_log "AUTO-BAN: IP $ip заблокирован на $ban_time_hours часов ($attempts попыток)"
|
||||||
}
|
}
|
||||||
@@ -562,7 +793,7 @@ unblock_expired_ips() {
|
|||||||
|
|
||||||
local message=" 🔓 IP РАЗБЛОКИРОВАН "$'\n'
|
local message=" 🔓 IP РАЗБЛОКИРОВАН "$'\n'
|
||||||
message="${message}🌐 IP: ${ip}"$'\n'
|
message="${message}🌐 IP: ${ip}"$'\n'
|
||||||
message="${message}🕐 Время разблокировки: $(date '+%d.%m.%Y %H:%M:%S')"
|
message="${message}🕐 Время разблокировки: $(notification_date '+%d.%m.%Y %H:%M:%S')"
|
||||||
notify_send "$message"
|
notify_send "$message"
|
||||||
|
|
||||||
unblocked=$((unblocked + 1))
|
unblocked=$((unblocked + 1))
|
||||||
@@ -687,11 +918,19 @@ monitor_ssh() {
|
|||||||
|
|
||||||
local ssh_logs=""
|
local ssh_logs=""
|
||||||
if command -v journalctl >/dev/null 2>&1; then
|
if command -v journalctl >/dev/null 2>&1; then
|
||||||
ssh_logs=$(journalctl -u ssh -u sshd --since="@${last_check}" --until="now" 2>/dev/null | grep -E "Accepted|Failed|Disconnected" || true)
|
# -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
|
else
|
||||||
ssh_logs=$(grep -E "sshd.*(Accepted|Failed|Disconnected)" /var/log/auth.log 2>/dev/null || true)
|
ssh_logs=$(grep -E "sshd.*(Accepted|Failed|Disconnected)" /var/log/auth.log 2>/dev/null | sort -u || true)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
declare -A SSH_ACCEPT_NOTIFY_KEYS
|
||||||
if [ -n "$ssh_logs" ]; then
|
if [ -n "$ssh_logs" ]; then
|
||||||
while IFS= read -r line; do
|
while IFS= read -r line; do
|
||||||
[[ -z "$line" ]] && continue
|
[[ -z "$line" ]] && continue
|
||||||
@@ -700,16 +939,38 @@ monitor_ssh() {
|
|||||||
user=$(sed -nE 's/.* for ([^ ]+) from .*/\1/p' <<<"$line")
|
user=$(sed -nE 's/.* for ([^ ]+) from .*/\1/p' <<<"$line")
|
||||||
ip="$(extract_ssh_client_ip "$line")"
|
ip="$(extract_ssh_client_ip "$line")"
|
||||||
if [ -n "$user" ]; then
|
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'
|
local message=" ✅ УСПЕШНОЕ SSH ПОДКЛЮЧЕНИЕ "$'\n'
|
||||||
message="${message}👤 Пользователь: ${user}"$'\n'
|
message="${message}👤 Пользователь: ${user}"$'\n'
|
||||||
message="${message}🌐 IP адрес: ${ip:-local}"$'\n'
|
message="${message}🌐 IP адрес: ${ip:-local}"$'\n'
|
||||||
message="${message}🕐 Время: $(date '+%d.%m.%Y %H:%M:%S')"
|
message="${message}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')"
|
||||||
notify_send "$message"
|
notify_send "$message"
|
||||||
write_log "SSH успешный вход: $user с IP ${ip:-local}"
|
write_log "SSH успешный вход: $user с IP ${ip:-local}"
|
||||||
if [ "$user" = "root" ]; then
|
if [ "$user" = "root" ]; then
|
||||||
local rmsg=" 🔑 SSH ВХОД ПОД ROOT "$'\n'
|
local rmsg=" 🔑 SSH ВХОД ПОД ROOT "$'\n'
|
||||||
rmsg="${rmsg}🌐 IP: ${ip:-local}"$'\n'
|
rmsg="${rmsg}🌐 IP: ${ip:-local}"$'\n'
|
||||||
rmsg="${rmsg}🕐 Время: $(date '+%d.%m.%Y %H:%M:%S')"
|
rmsg="${rmsg}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')"
|
||||||
notify_send "$rmsg"
|
notify_send "$rmsg"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -738,7 +999,7 @@ monitor_ssh() {
|
|||||||
message="${message}👤 Пользователь: ${user}"$'\n'
|
message="${message}👤 Пользователь: ${user}"$'\n'
|
||||||
message="${message}🌐 IP адрес: ${ip}"$'\n'
|
message="${message}🌐 IP адрес: ${ip}"$'\n'
|
||||||
message="${message}📊 Попытка ${current_attempts} из ${MAX_ATTEMPTS}"$'\n'
|
message="${message}📊 Попытка ${current_attempts} из ${MAX_ATTEMPTS}"$'\n'
|
||||||
message="${message}🕐 Время: $(date '+%d.%m.%Y %H:%M:%S')"
|
message="${message}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')"
|
||||||
notify_send "$message"
|
notify_send "$message"
|
||||||
fi
|
fi
|
||||||
write_log "SSH неудачная попытка: $user с IP $ip (попытка $current_attempts из $MAX_ATTEMPTS)"
|
write_log "SSH неудачная попытка: $user с IP $ip (попытка $current_attempts из $MAX_ATTEMPTS)"
|
||||||
@@ -853,7 +1114,7 @@ monitor_sudo() {
|
|||||||
fi
|
fi
|
||||||
message="${message}💻 Команда: ${sudo_cmd}"$'\n'
|
message="${message}💻 Команда: ${sudo_cmd}"$'\n'
|
||||||
message="${message}📁 Директория: ${pwd:-unknown}"$'\n'
|
message="${message}📁 Директория: ${pwd:-unknown}"$'\n'
|
||||||
message="${message}🕐 Время: $(date '+%d.%m.%Y %H:%M:%S')"
|
message="${message}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')"
|
||||||
notify_send "$message"
|
notify_send "$message"
|
||||||
write_log "SUDO: $user выполнил $sudo_cmd${run_as:+ (USER=$run_as)}"
|
write_log "SUDO: $user выполнил $sudo_cmd${run_as:+ (USER=$run_as)}"
|
||||||
fi
|
fi
|
||||||
@@ -894,7 +1155,7 @@ monitor_security_events() {
|
|||||||
hk="$(echo "$chunk" | grep -Ei "REMOTE HOST IDENTIFICATION HAS CHANGED" | head -n 3)"
|
hk="$(echo "$chunk" | grep -Ei "REMOTE HOST IDENTIFICATION HAS CHANGED" | head -n 3)"
|
||||||
local m=" 🔐 ВНИМАНИЕ: изменение SSH host key / MITM? "$'\n'
|
local m=" 🔐 ВНИМАНИЕ: изменение SSH host key / MITM? "$'\n'
|
||||||
m="${m}$(printf '%s\n' "$hk")"
|
m="${m}$(printf '%s\n' "$hk")"
|
||||||
m="${m}🕐 Время: $(date '+%d.%m.%Y %H:%M:%S')"
|
m="${m}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')"
|
||||||
notify_send "$m"
|
notify_send "$m"
|
||||||
write_log "SECURITY: обнаружено сообщение об изменении host key"
|
write_log "SECURITY: обнаружено сообщение об изменении host key"
|
||||||
fi
|
fi
|
||||||
@@ -915,7 +1176,7 @@ monitor_security_events() {
|
|||||||
local bm=" 🧨 ВОЗМОЖНЫЙ МАССОВЫЙ БРУТФОРС SSH "$'\n'
|
local bm=" 🧨 ВОЗМОЖНЫЙ МАССОВЫЙ БРУТФОРС SSH "$'\n'
|
||||||
bm="${bm}🌐 IP: ${ip}"$'\n'
|
bm="${bm}🌐 IP: ${ip}"$'\n'
|
||||||
bm="${bm}📊 Неудачных попыток за ~${BRUTE_WINDOW_SEC}s: ${count}"$'\n'
|
bm="${bm}📊 Неудачных попыток за ~${BRUTE_WINDOW_SEC}s: ${count}"$'\n'
|
||||||
bm="${bm}🕐 Время: $(date '+%d.%m.%Y %H:%M:%S')"
|
bm="${bm}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')"
|
||||||
notify_send "$bm"
|
notify_send "$bm"
|
||||||
write_log "SECURITY: массовый брутфорс? IP=$ip fails=${count} за окно ${BRUTE_WINDOW_SEC}s"
|
write_log "SECURITY: массовый брутфорс? IP=$ip fails=${count} за окно ${BRUTE_WINDOW_SEC}s"
|
||||||
fi
|
fi
|
||||||
@@ -927,6 +1188,163 @@ monitor_security_events() {
|
|||||||
echo "$now" >"$LAST_SECURITY_EVENTS_FILE"
|
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')"
|
||||||
|
notify_send "$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_send "$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_send "$msg_f"
|
||||||
|
write_log "LOGIND: сбой: $line"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done <<<"$logind_lines"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$current_time" >"$LAST_LOGIND_CHECK_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Для Telegram и отчётов: «hostname (IPv4)»; без доступного IPv4 — только hostname.
|
||||||
|
server_display_name_with_ip() {
|
||||||
|
local hn ip
|
||||||
|
hn=$(hostname 2>/dev/null | tr -d '\r\n')
|
||||||
|
[ -z "$hn" ] && hn="(unknown)"
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Ежедневный отчёт
|
# Ежедневный отчёт
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -1000,7 +1418,7 @@ send_daily_report() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
local message=" 📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH МОНИТОРИНГА "$'\n'
|
local message=" 📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH МОНИТОРИНГА "$'\n'
|
||||||
message="${message}🖥️ Сервер: $(hostname)"$'\n'
|
message="${message}🖥️ Сервер: $(server_display_name_with_ip)"$'\n'
|
||||||
message="${message}🕐 Время отчета: ${current_time}"$'\n'
|
message="${message}🕐 Время отчета: ${current_time}"$'\n'
|
||||||
message="${message}"$'\n'
|
message="${message}"$'\n'
|
||||||
message="${message} 📈 СТАТИСТИКА ЗА ПОСЛЕДНИЕ 24 ЧАСА: "$'\n'
|
message="${message} 📈 СТАТИСТИКА ЗА ПОСЛЕДНИЕ 24 ЧАСА: "$'\n'
|
||||||
@@ -1077,10 +1495,10 @@ send_heartbeat() {
|
|||||||
local timestamp
|
local timestamp
|
||||||
timestamp=$(date '+%s')
|
timestamp=$(date '+%s')
|
||||||
local timestamp_human
|
local timestamp_human
|
||||||
timestamp_human=$(date '+%d.%m.%Y %H:%M:%S')
|
timestamp_human=$(notification_date '+%d.%m.%Y %H:%M:%S')
|
||||||
|
|
||||||
local message=" ❤️ Heartbeat - скрипт мониторинга работает "$'\n'
|
local message=" ❤️ Heartbeat - скрипт мониторинга работает "$'\n'
|
||||||
message="${message}🖥️ Сервер: $(hostname)"$'\n'
|
message="${message}🖥️ Сервер: $(server_display_name_with_ip)"$'\n'
|
||||||
message="${message}🕐 Время: ${timestamp_human}"
|
message="${message}🕐 Время: ${timestamp_human}"
|
||||||
|
|
||||||
notify_send "$message"
|
notify_send "$message"
|
||||||
@@ -1134,16 +1552,18 @@ write_health_and_metrics() {
|
|||||||
|
|
||||||
send_startup_notification() {
|
send_startup_notification() {
|
||||||
local timestamp
|
local timestamp
|
||||||
timestamp=$(date '+%d.%m.%Y %H:%M:%S')
|
timestamp=$(notification_date '+%d.%m.%Y %H:%M:%S')
|
||||||
local whitelist_count=$((${#WHITELIST_IPS[@]} + ${#WHITELIST_SUBNETS[@]}))
|
local whitelist_count=$((${#WHITELIST_IPS[@]} + ${#WHITELIST_SUBNETS[@]}))
|
||||||
|
|
||||||
local message=" ✅ СКРИПТ МОНИТОРИНГА ЗАПУЩЕН "$'\n'
|
local message=" ✅ СКРИПТ МОНИТОРИНГА ЗАПУЩЕН "$'\n'
|
||||||
message="${message}🖥️ Сервер: $(hostname)"$'\n'
|
message="${message}📌 Версия: ${SSH_MONITOR_VERSION:-?}"$'\n'
|
||||||
|
message="${message}🖥️ Сервер: $(server_display_name_with_ip)"$'\n'
|
||||||
message="${message}🕐 Время запуска: ${timestamp}"$'\n'
|
message="${message}🕐 Время запуска: ${timestamp}"$'\n'
|
||||||
message="${message}📊 Мониторинг SSH и SUDO активирован"$'\n'
|
message="${message}📊 Мониторинг SSH, SUDO и systemd-logind активирован"$'\n'
|
||||||
message="${message}🚫 Автоблокировка IP после ${MAX_ATTEMPTS} попыток на ${BAN_TIME} секунд"$'\n'
|
message="${message}🚫 Автоблокировка IP после ${MAX_ATTEMPTS} попыток на ${BAN_TIME} секунд"$'\n'
|
||||||
message="${message}⚪ Белый список: ${whitelist_count} IP/подсетей"$'\n'
|
message="${message}⚪ Белый список: ${whitelist_count} IP/подсетей"$'\n'
|
||||||
message="${message}❤️ Heartbeat будет отправляться каждые 12 часов"
|
message="${message}❤️ Heartbeat будет отправляться каждые 12 часов"$'\n'
|
||||||
|
message="${message}📢 Каналы уведомлений (все по списку): $(notify_chain_human)"
|
||||||
|
|
||||||
notify_send "$message"
|
notify_send "$message"
|
||||||
write_log "Скрипт мониторинга запущен (белый список: $whitelist_count записей)"
|
write_log "Скрипт мониторинга запущен (белый список: $whitelist_count записей)"
|
||||||
@@ -1154,9 +1574,9 @@ send_shutdown_notification() {
|
|||||||
SHUTDOWN_NOTIFIED=1
|
SHUTDOWN_NOTIFIED=1
|
||||||
|
|
||||||
local timestamp
|
local timestamp
|
||||||
timestamp=$(date '+%d.%m.%Y %H:%M:%S')
|
timestamp=$(notification_date '+%d.%m.%Y %H:%M:%S')
|
||||||
local message=" ⚠️ СКРИПТ МОНИТОРИНГА ОСТАНОВЛЕН "$'\n'
|
local message=" ⚠️ СКРИПТ МОНИТОРИНГА ОСТАНОВЛЕН "$'\n'
|
||||||
message="${message}🖥️ Сервер: $(hostname)"$'\n'
|
message="${message}🖥️ Сервер: $(server_display_name_with_ip)"$'\n'
|
||||||
message="${message}🕐 Время остановки: ${timestamp}"
|
message="${message}🕐 Время остановки: ${timestamp}"
|
||||||
|
|
||||||
notify_send "$message"
|
notify_send "$message"
|
||||||
@@ -1180,6 +1600,7 @@ main() {
|
|||||||
monitor_ssh
|
monitor_ssh
|
||||||
monitor_sudo
|
monitor_sudo
|
||||||
monitor_security_events
|
monitor_security_events
|
||||||
|
monitor_logind
|
||||||
check_daily_report
|
check_daily_report
|
||||||
check_heartbeat
|
check_heartbeat
|
||||||
write_health_and_metrics
|
write_health_and_metrics
|
||||||
@@ -1198,16 +1619,29 @@ main() {
|
|||||||
|
|
||||||
run_check_config() {
|
run_check_config() {
|
||||||
printf 'Проверка конфигурации ssh-monitor\n'
|
printf 'Проверка конфигурации ssh-monitor\n'
|
||||||
|
printf 'SSH_MONITOR_VERSION=%s\n' "${SSH_MONITOR_VERSION:-}"
|
||||||
printf 'CONFIG_FILE=%s\n' "$CONFIG_FILE"
|
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 'TELEGRAM: %s\n' "$([ -n "$TELEGRAM_BOT_TOKEN" ] && echo настроен || echo НЕ настроен)"
|
||||||
printf 'BACKUP_WEBHOOK_URL: %s\n' "$([ -n "${BACKUP_WEBHOOK_URL:-}" ] && echo настроен || echo не задан)"
|
printf 'BACKUP_WEBHOOK_URL: %s\n' "$([ -n "${BACKUP_WEBHOOK_URL:-}" ] && echo настроен || echo не задан)"
|
||||||
printf 'LOG_FILE=%s\n' "$LOG_FILE"
|
printf 'LOG_FILE=%s\n' "$LOG_FILE"
|
||||||
printf 'DAILY_REPORT_HOUR=%s\n' "$DAILY_REPORT_HOUR"
|
printf 'DAILY_REPORT_HOUR=%s\n' "$DAILY_REPORT_HOUR"
|
||||||
printf 'DAILY_REPORT_TZ=%s (пусто = зона процесса/date)\n' "${DAILY_REPORT_TZ:-}"
|
printf 'DAILY_REPORT_TZ=%s (пусто = зона процесса/date)\n' "${DAILY_REPORT_TZ:-}"
|
||||||
|
printf 'NOTIFY_TZ=%s (пусто = DAILY_REPORT_TZ или зона процесса; см. README)\n' "${NOTIFY_TZ:-}"
|
||||||
|
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 '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 'BRUTE_WINDOW_SEC=%s BRUTE_MIN_FAILS=%s\n' "$BRUTE_WINDOW_SEC" "$BRUTE_MIN_FAILS"
|
||||||
printf 'PROMETHEUS_TEXTFILE_DIR=%s\n' "${PROMETHEUS_TEXTFILE_DIR:-}"
|
printf 'PROMETHEUS_TEXTFILE_DIR=%s\n' "${PROMETHEUS_TEXTFILE_DIR:-}"
|
||||||
printf 'HEALTHCHECK_STATUS_FILE=%s\n' "${HEALTHCHECK_STATUS_FILE:-}"
|
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 не задан)"
|
||||||
if bash -n "$0" 2>/dev/null; then
|
if bash -n "$0" 2>/dev/null; then
|
||||||
printf 'bash -n: OK\n'
|
printf 'bash -n: OK\n'
|
||||||
else
|
else
|
||||||
@@ -1245,7 +1679,7 @@ trap send_shutdown_notification EXIT INT TERM
|
|||||||
mkdir -p "$(dirname "$LOG_FILE")"
|
mkdir -p "$(dirname "$LOG_FILE")"
|
||||||
touch "$LOG_FILE"
|
touch "$LOG_FILE"
|
||||||
|
|
||||||
write_log "Конфигурация загружена из $CONFIG_FILE (или дефолты)"
|
write_log "Конфигурация загружена из $CONFIG_FILE (или дефолты), версия ${SSH_MONITOR_VERSION:-?}, очередь оповещений: $(notify_chain_human)"
|
||||||
|
|
||||||
if [ "${DRY_RUN:-0}" = "1" ]; then
|
if [ "${DRY_RUN:-0}" = "1" ]; then
|
||||||
write_log "Режим DRY-RUN: iptables/ip6tables не меняются, уведомления печатаются в stderr"
|
write_log "Режим DRY-RUN: iptables/ip6tables не меняются, уведомления печатаются в stderr"
|
||||||
|
|||||||
@@ -1,21 +1,65 @@
|
|||||||
TELEGRAM_BOT_TOKEN="replace_with_telegram_bot_token"
|
TELEGRAM_BOT_TOKEN="replace_with_telegram_bot_token"
|
||||||
TELEGRAM_CHAT_ID="replace_with_telegram_chat_id"
|
TELEGRAM_CHAT_ID="replace_with_telegram_chat_id"
|
||||||
|
|
||||||
# Резервный webhook при недоступности Telegram (JSON {"text":"..."}, совместимо со Slack Incoming Webhook)
|
# Резервный webhook, если ВСЕ каналы из NOTIFY_CHAIN не смогли доставить сообщение (JSON {"text":"..."}).
|
||||||
|
# ВАЖНО: BACKUP_WEBHOOK_URL НЕ заменяет основной канал — без хотя бы одного из telegram/email
|
||||||
|
# скрипт сразу завершится с ошибкой в stderr: «Не настроен ни один канал отправки оповещений» (код 1),
|
||||||
|
# в том числе при --check-config и --dry-run. Сначала настройте Telegram или SMTP ниже.
|
||||||
BACKUP_WEBHOOK_URL=""
|
BACKUP_WEBHOOK_URL=""
|
||||||
|
|
||||||
|
# --- Очередь оповещений (NOTIFY_ORDER) ---
|
||||||
|
# CSV: telegram, email (или сокращения tg, mail).
|
||||||
|
# При каждом событии скрипт ПЫТАЕТСЯ ОТПРАВИТЬ ВО ВСЕ перечисленные каналы по порядку (не только в первый успешный).
|
||||||
|
# Неудача одного канала не отменяет отправку в остальные; подробности — в LOG_FILE (WARN по каналам).
|
||||||
|
#
|
||||||
|
# Пустая строка NOTIFY_ORDER="" = авто: в цепочку NOTIFY_CHAIN попадают ТОЛЬКО реально настроенные каналы,
|
||||||
|
# порядок по умолчанию: telegram → email (см. критерии «настроен» в README, раздел про каналы).
|
||||||
|
#
|
||||||
|
# Если после сборки цепочки каналов список ПУСТ (ни один канал не настроен, либо в NOTIFY_ORDER только
|
||||||
|
# неизвестные токены), скрипт НЕ запускает мониторинг и выходит с ошибкой:
|
||||||
|
# Не настроен ни один канал отправки оповещений
|
||||||
|
#
|
||||||
|
# Примеры явной очереди:
|
||||||
|
# NOTIFY_ORDER="telegram,email"
|
||||||
|
# NOTIFY_ORDER="telegram"
|
||||||
|
# NOTIFY_ORDER="email"
|
||||||
|
NOTIFY_ORDER=""
|
||||||
|
|
||||||
|
# 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"
|
LOG_FILE="/var/log/ssh_monitor.log"
|
||||||
LAST_HEARTBEAT_FILE="/var/log/last_heartbeat.txt"
|
LAST_HEARTBEAT_FILE="/var/log/last_heartbeat.txt"
|
||||||
LAST_REPORT_FILE="/var/log/last_daily_report.txt"
|
LAST_REPORT_FILE="/var/log/last_daily_report.txt"
|
||||||
LAST_SSH_CHECK_FILE="/var/log/last_ssh_check"
|
LAST_SSH_CHECK_FILE="/var/log/last_ssh_check"
|
||||||
LAST_SUDO_CHECK_FILE="/var/log/last_sudo_check"
|
LAST_SUDO_CHECK_FILE="/var/log/last_sudo_check"
|
||||||
LAST_SECURITY_EVENTS_FILE="/var/log/last_security_events_check"
|
LAST_SECURITY_EVENTS_FILE="/var/log/last_security_events_check"
|
||||||
|
LAST_LOGIND_CHECK_FILE="/var/log/last_logind_check"
|
||||||
BAN_LIST_FILE="/var/log/ssh_bans.txt"
|
BAN_LIST_FILE="/var/log/ssh_bans.txt"
|
||||||
|
|
||||||
|
# systemd-logind: 1 = включено, 0 = выключено
|
||||||
|
ENABLE_LOGIND_MONITOR="1"
|
||||||
|
LOGIND_NOTIFY_NEW="1"
|
||||||
|
LOGIND_NOTIFY_REMOVED="0"
|
||||||
|
LOGIND_NOTIFY_FAILED="1"
|
||||||
|
# 1 = не дублировать Telegram для SSH-сессий logind (Type=ssh или Service=*sshd*; см. README)
|
||||||
|
LOGIND_SKIP_REMOTE="1"
|
||||||
|
|
||||||
DAILY_REPORT_HOUR="9"
|
DAILY_REPORT_HOUR="9"
|
||||||
# IANA-зона для «9:00» и календарной даты отчёта; пусто = как у команды date (обычно локаль сервера, см. timedatectl)
|
# IANA-зона для «9:00» и календарной даты отчёта; пусто = как у команды date (обычно локаль сервера, см. timedatectl)
|
||||||
DAILY_REPORT_TZ=""
|
DAILY_REPORT_TZ=""
|
||||||
DAILY_REPORT_TOP_IPS="5"
|
DAILY_REPORT_TOP_IPS="5"
|
||||||
|
# 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"
|
||||||
|
|
||||||
BAN_TIME="3600"
|
BAN_TIME="3600"
|
||||||
MAX_ATTEMPTS="3"
|
MAX_ATTEMPTS="3"
|
||||||
|
|||||||
Reference in New Issue
Block a user