commit 10704e0f8800b01b0fc7aee45246a320a603626d Author: PTah Date: Tue Jul 14 20:43:55 2026 +1000 chore(home): mirror from kalinamall (b0abf99) with papatramp URLs diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7369e4f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Агентские скрипты — LF в git и на Linux (manifest SHA256 = git blob). +ssh-monitor text eol=lf +*.sh text eol=lf +version.txt text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..705adb4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.DS_Store +CalculatorApp/ +.cursor/ + +# .env — не коммитить; ssh-monitor.conf с секретами допустим только в закрытом зеркале (не в публичном GitHub) +.env +.env.* +# Артефакты локальной сборки (релизные архивы грузятся в GitHub Releases, в git не коммитим) +ssh-monitor-*.tar.gz + +# Локальный загрузчик Rutube / yt-dlp — живёт в другом репозитории +rutube-download.py +.ytdl-archive.txt +# Локальные инструменты разработки (не в Gitea/GitHub) +.local/ +scripts/build-release-manifest.sh diff --git a/IPSET-NOTES.txt b/IPSET-NOTES.txt new file mode 100644 index 0000000..992cd6e --- /dev/null +++ b/IPSET-NOTES.txt @@ -0,0 +1,24 @@ +ssh-monitor (ветка ipset) — что сделать на сервере +================================================== + +1) apt install ipset + +2) Скопируйте ssh-monitor в /usr/local/bin/ (или как у вас принято), права +x. + +3) Каталог /etc/iptables должен существовать, если нужен save правил: + mkdir -p /etc/iptables + Скрипт пишет: + - /etc/iptables/ipset.rules + - /etc/iptables/rules.v4 и rules.v6 (если есть iptables-save) + +4) Загрузка ipset после перезагрузки (Debian/Ubuntu, пример): + apt install ipset-persistent + либо свой systemd unit: ipset restore -f /etc/iptables/ipset.rules + +5) UFW / nftables: если фактический фильтр не iptables-nft совместимый с -m set, + правило может не примениться — проверяйте «iptables -L INPUT -n» и счётчики. + +6) С таймаутом ipset ядро само убирает IP из набора; скрипт лишь чистит файл бан-листа + и при расхождении восстанавливает элемент в ipset (verify_ban_list_vs_ipset). + +Версия скрипта: см. SSH_MONITOR_VERSION в начале файла. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4b1d49f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 PTah + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2cc4346 --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +# Версия берётся из ssh-monitor (SSH_MONITOR_VERSION). +.PHONY: dist clean-dist manifest +VERSION := $(shell sed -n 's/^SSH_MONITOR_VERSION="\(.*\)".*/\1/p' ssh-monitor | head -n1) +DIST := ssh-monitor-$(VERSION).tar.gz + +dist: $(DIST) + +manifest: + @test -x .local/build-release-manifest.sh || (echo "Сначала: ./contrib/install-git-hooks.sh" >&2; exit 1) + ./.local/build-release-manifest.sh + +$(DIST): ssh-monitor + git archive --format=tar.gz --prefix=ssh-monitor-$(VERSION)/ -o $(DIST) HEAD + +clean-dist: + rm -f ssh-monitor-*.tar.gz diff --git a/README.md b/README.md new file mode 100644 index 0000000..a8ad978 --- /dev/null +++ b/README.md @@ -0,0 +1,243 @@ +# ssh-monitor + +**Версия:** `2.3.0-SAC` + +Bash-мониторинг **SSH**, **sudo**, **systemd-logind**: Telegram/email, бан IP (ipset), ежедневный отчёт, heartbeat. + +**Security Alert Center:** модуль **`sac-client.sh`**, режим `UseSAC` (`off` | `exclusive` | `dual` | `fallback`) — [контракт SAC](https://git.papatramp.ru/PapaTramp/security-alert-center/src/branch/main/docs/agent-integration.md). Ingest / таймауты / fallback: [docs/sac-ingest.ru.md](docs/sac-ingest.ru.md). **Основной способ обновления на Linux** — кнопка **«Обновить ssh-monitor (SSH)»** в SAC: сервер передаёт `REPO_URL` / `GIT_BRANCH`, updater сам подтягивает git, выставляет права на конфиг/spool/state и перезапускает сервис. Cron/systemd timer — опционально. + +**Безопасность (2.1.7+):** агент работает от root; доверяйте только своему git-зеркалу (`REPO_URL`) и `/etc/ssh-monitor.conf` (`chmod 600`). С **2.3.0** конфиг читается **без `source`** (whitelist ключей, без выполнения bash из файла). Updater дописывает обязательные ключи и **на каждом прогоне** применяет `chmod`/`chown` к конфигу, SAC-spool и state-файлам. Подробнее: [docs/security-roadmap.ru.md](docs/security-roadmap.ru.md). + +## Состав репозитория + +| Файл | Назначение | +|------|------------| +| **`ssh-monitor`** | Основной цикл: разбор журналов (**journalctl**: sshd, sudo, «тяжёлые» события, **systemd-logind**), цепочка уведомлений (Telegram, SMTP через **python3**), при **`ENABLE_IP_BAN=1`** — учёт неудачных попыток, бан в **ipset** и согласование с **iptables**/**ip6tables**, ежедневный отчёт, heartbeat, опционально **Prometheus** textfile и JSON health. Режимы: **`--check-config`**, **`--dry-run`**. | +| **`ssh-monitor-watchdog`** | Отдельный скрипт (обычно по **systemd timer**): проверяет, что сервис монитора активен и файл heartbeat не старше порога; при сбое выполняет **`systemctl restart`** и шлёт уведомление в Telegram. Читает `TELEGRAM_*` и watchdog-параметры из `/etc/ssh-monitor.conf`. | +| `update_ssh_monitor.sh` | Автообновление: git + manifest verify, `sac-client.sh`, перезапуск `ssh-monitor.service`. См. [docs/auto-update.ru.md](docs/auto-update.ru.md). | + +**Системные пакеты и зависимости** (что поставить на сервер до запуска): пошагово в [docs/install-prerequisites.ru.md](docs/install-prerequisites.ru.md). + +## Конфигурация + +> **Закрытый инстанс Gitea:** в доверенном закрытом репозитории допустимо хранить **`ssh-monitor.conf`** с боевыми токенами (файл не в `.gitignore`). В публичном [GitHub](https://git.papatramp.ru/PapaTramp/ssh-monitor) такой файл игнорируется — используйте только `ssh-monitor.conf.example`. + +Скрипт читает параметры из `/etc/ssh-monitor.conf` в формате `KEY="value"` (парсер с whitelist ключей; произвольный bash в файле не выполняется). + +1. Скопируйте пример: + - `sudo cp ./ssh-monitor.conf.example /etc/ssh-monitor.conf` +2. Ограничьте доступ: + - `sudo chmod 600 /etc/ssh-monitor.conf` +3. Заполните минимум — **хотя бы один канал доставки оповещений** (иначе скрипт сразу завершится с ошибкой; см. абзац **«Обязательное условие»** ниже и раздел **«Каналы оповещений и пустой NOTIFY_CHAIN»**): + - для **Telegram**: `TELEGRAM_BOT_TOKEN` и `TELEGRAM_CHAT_ID`; + - или настройте **почту (SMTP)** по переменным ниже при `NOTIFY_ORDER=""` (автовыбор каналов). + +**Обязательное условие:** после загрузки конфигурации в цепочке **`NOTIFY_CHAIN`** должен быть **минимум один** канал (`telegram` или `email`). Если каналов **нет** (пустой `NOTIFY_ORDER` и ни Telegram, ни SMTP не удовлетворяют критериям «настроен», либо в `NOTIFY_ORDER` остались только неизвестные имена), скрипт **не входит** в основной цикл и завершается с кодом **1**, в stderr: **`Не настроен ни один канал отправки оповещений`**. Это же правило действует для **`--check-config`** и **`--dry-run`**. + +Поддерживаемые параметры: + +- `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID` +- `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** (см. таблицу каналов ниже) +- `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_ENABLED` (1/0 — отчёт с агента; 0 = только SAC), `DAILY_REPORT_TZ` (опционально), `NOTIFY_TZ` (опционально), `SERVER_DISPLAY_NAME` (опционально), `SSH_ACCEPT_NOTIFY_DEDUP_SEC`, `DAILY_REPORT_TOP_IPS` +- `BRUTE_WINDOW_SEC`, `BRUTE_MIN_FAILS`, `BRUTE_NOTIFY_COOLDOWN_SEC` +- `PROMETHEUS_TEXTFILE_DIR` — каталог для `ssh_monitor.prom` (совместимость с node_exporter textfile collector) +- `HEALTHCHECK_STATUS_FILE` — путь к JSON-файлу с меткой последней итерации цикла +- `ENABLE_IP_BAN` — `1` (по умолчанию): мониторинг и автобан через ipset; `0`: только мониторинг и уведомления (без ipset/бана) +- `BAN_TIME`, `MAX_ATTEMPTS`, `BAN_CHECK_INTERVAL`, `MONITOR_INTERVAL` +- `WHITELIST_IPS`, `WHITELIST_SUBNETS` (CSV, например `ip1,ip2`) +- `WATCHDOG_MAX_HEARTBEAT_AGE`, `WATCHDOG_LOG_FILE`, `WATCHDOG_SERVICE_NAME`, `WATCHDOG_NOTIFY_ON_RECOVERY` + +Если файл `/etc/ssh-monitor.conf` отсутствует или часть значений пустая, используются значения по умолчанию из скрипта. + +### Описание переменных в `/etc/ssh-monitor.conf` + +- `TELEGRAM_BOT_TOKEN` — токен Telegram-бота для отправки уведомлений. +- `TELEGRAM_CHAT_ID` — ID чата/пользователя, куда отправляются уведомления. +- `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**; см. таблицу «настроен»). +- `LOG_FILE` — путь к основному лог-файлу скрипта. +- `LAST_HEARTBEAT_FILE` — файл с timestamp последнего heartbeat-сообщения. +- `LAST_REPORT_FILE` — дата последнего ежедневного отчёта в формате `YYYY-MM-DD` (старый формат Unix-времени при первом запуске будет автоматически интерпретирован). +- `LAST_SSH_CHECK_FILE` — файл с меткой времени последней проверки SSH-событий. +- `LAST_SUDO_CHECK_FILE` — файл с меткой времени последней проверки sudo-событий. +- `LAST_SECURITY_EVENTS_FILE` — метка последней проверки «тяжёлых» событий безопасности в журнале. +- `LAST_LOGIND_CHECK_FILE` — метка последней обработки журнала `systemd-logind` (unix-время); при первом запуске подтягивается около 30 минут истории. +- `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_ENABLED` — `1` (по умолчанию) отправлять `report.daily.ssh` с агента; **`0`** — не слать (суточный отчёт формирует только SAC, см. `SAC_DAILY_REPORT_*`). +- `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`. +- `SERVER_DISPLAY_NAME` — подпись в строке **«🖥️ Сервер»** во всех уведомлениях. **Пусто** — `hostname` и при возможности основной IPv4 (`hostname (10.x.x.x)`). Задайте явное имя, если несколько хостов с одинаковым hostname шлют в один чат. Подробнее: [docs/notifications.ru.md](docs/notifications.ru.md). +- `SERVER_IPV4` — опциональный override для SAC `host.ipv4`. Если пусто, IPv4 определяется автоматически. +- `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`. +- `BRUTE_MIN_FAILS` — минимум неудачных попыток за окно для тревоги. +- `BRUTE_NOTIFY_COOLDOWN_SEC` — пауза между повторными уведомлениями по одному и тому же IP. +- `PROMETHEUS_TEXTFILE_DIR` — если задан существующий каталог, на каждой итерации пишется `ssh_monitor.prom` с метрикой `ssh_monitor_last_loop_unixtime`. +- `HEALTHCHECK_STATUS_FILE` — если задан, на каждой итерации обновляется JSON `{ "ts", "hostname", "dry_run" }`. +- `ENABLE_IP_BAN` — включить автобан IP (`1`) или работать только как монитор с уведомлениями (`0`; **ipset** не требуется). +- `BAN_TIME` — длительность бана IP в секундах (используется при `ENABLE_IP_BAN=1`). +- `MAX_ATTEMPTS` — число неудачных SSH-попыток до автоматического бана. +- `BAN_CHECK_INTERVAL` — интервал проверки просроченных банов и аудита согласованности бан-листа с firewall (секунды). +- `MONITOR_INTERVAL` — пауза между итерациями основного цикла мониторинга (секунды). +- `WHITELIST_IPS` — белый список IP через запятую (IPv4 и IPv6 — точное совпадение). +- `WHITELIST_SUBNETS` — белый список подсетей **только IPv4** CIDR через запятую (например `10.0.0.0/24`). +- `WATCHDOG_MAX_HEARTBEAT_AGE` — через сколько секунд heartbeat считается устаревшим. +- `WATCHDOG_LOG_FILE` — лог-файл watchdog-скрипта. +- `WATCHDOG_SERVICE_NAME` — имя systemd-сервиса, который контролирует watchdog (по умолчанию `ssh-monitor.service`). +- `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. + +Ограничения на хостах **без journald** и нюансы **`/var/log/auth.log`**: см. раздел 4 в [docs/install-prerequisites.ru.md](docs/install-prerequisites.ru.md). + +### Идентификация сервера в уведомлениях + +Во **всех** оповещениях (SSH, sudo, бан IP, logind, heartbeat, ежедневный отчёт и т.д.) после заголовка автоматически добавляется строка **«🖥️ Сервер: …»** — формат `hostname (IPv4)` или только `hostname`, если IPv4 определить не удалось. Логика в `notify_send()`; дублирование в тексте сообщения не требуется. + +- Утилиты **`hostname`** и **`ip`** (см. [docs/install-prerequisites.ru.md](docs/install-prerequisites.ru.md)). +- Опционально **`SERVER_DISPLAY_NAME`** в конфиге — фиксированная подпись вместо hostname (удобно при нескольких серверах в одном Telegram-чате). +- Типы сообщений и примеры: [docs/notifications.ru.md](docs/notifications.ru.md). + +### Обновление агента + +**Рекомендуемый путь — SAC:** в карточке Linux-хоста кнопка **«Обновить ssh-monitor (SSH)»**. Участие админа на сервере не требуется: SAC передаёт `REPO_URL`/`GIT_BRANCH`, updater выполняет `git pull`, синхронизирует `sac-client.sh`, дописывает ключи конфига, **выставляет права** на конфиг/spool/state и перезапускает `ssh-monitor.service`. + +Скрипт **`update_ssh_monitor.sh`** также можно запускать вручную или по расписанию (cron/systemd timer) с тем же `REPO_URL` — поведение идентично. + +Пошаговая настройка: [docs/auto-update.ru.md](docs/auto-update.ru.md). + +## Режимы запуска + +Обычный запуск (root): + +```bash +sudo bash ./ssh-monitor +``` + +Проверка конфигурации и синтаксиса **без** запуска цикла, **без** изменений `iptables` и без обязательного Telegram: + +```bash +sudo bash ./ssh-monitor --check-config +``` + +Режим **dry-run** (правила firewall не меняются; уведомления выводятся в stderr вместо отправки): + +```bash +sudo bash ./ssh-monitor --dry-run +``` + +## Проверка синтаксиса + +```bash +bash -n ./ssh-monitor +``` + +## Релизный архив + +Версия задаётся в **`SSH_MONITOR_VERSION`** в файле **`ssh-monitor`** и дублируется в **`version.txt`** (та же строка, что в шапке README). Модуль **`sac-client.sh`** использует ту же версию для SAC `product_version`. **Release manifest** (`release/manifest-*.json`): [docs/release-manifest.ru.md](docs/release-manifest.ru.md) — после clone: `.\contrib\install-git-hooks.ps1` (Windows) или `./contrib/install-git-hooks.sh` (Linux/Git Bash). + +Сборка tarball из текущего git-дерева: + +```bash +make dist +``` + +Появится файл `ssh-monitor-<версия>.tar.gz` (через `git archive`). Готовые архивы для установки без клона репозитория прикладываются к [релизам на GitHub](https://git.papatramp.ru/PapaTramp/ssh-monitor/releases). + +## Установка скрипта, конфигурации и systemd + +### Автоматическая установка (рекомендуется) + +```bash +sudo ./first_deploy.sh +# или: sudo ./update_ssh_monitor.sh --deploy +``` + +См. [docs/auto-update.ru.md](docs/auto-update.ru.md) — клон из git, копирование в `/usr/local/bin/`, `sac-client.sh`, пример `/etc/ssh-monitor.conf`, unit systemd. + +### Ручная установка + +1. Скопируйте скрипт в постоянное место: + - `sudo install -m 750 ./ssh-monitor /usr/local/bin/ssh-monitor` + - `sudo install -m 644 ./sac-client.sh /usr/local/bin/sac-client.sh` +2. Убедитесь, что конфиг создан (см. раздел **«Конфигурация»** выше): + - `sudo cp ./ssh-monitor.conf.example /etc/ssh-monitor.conf` + - `sudo chmod 600 /etc/ssh-monitor.conf` +3. Создайте unit-файл `/etc/systemd/system/ssh-monitor.service` (или `sudo cp ssh-monitor.service.example ...`): + +```ini +[Unit] +Description=SSH Monitor with Telegram alerts +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/ssh-monitor +Restart=always +RestartSec=5 +User=root + +[Install] +WantedBy=multi-user.target +``` + +4. Включите и запустите сервис: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable ssh-monitor +sudo systemctl start ssh-monitor +``` + +5. Проверка состояния и логов: + +```bash +sudo systemctl status ssh-monitor +sudo journalctl -u ssh-monitor -f +``` + +## Ротация логов + +Пример для `logrotate` лежит в репозитории: `contrib/logrotate.d/ssh-monitor`. Скопируйте файл в `/etc/logrotate.d/` и при необходимости поправьте пути под ваши `LOG_FILE` / `WATCHDOG_LOG_FILE` и другие файлы состояния в `/var/log/` (в т.ч. `LAST_*`, если решите их ротировать отдельно). + +## Watchdog (автоматическое восстановление) + +Watchdog проверяет активность `ssh-monitor.service` и актуальность heartbeat (`LAST_HEARTBEAT_FILE`). При сбое выполняет `systemctl restart` и отправляет уведомление в Telegram. + +Пошаговая установка скрипта **ssh-monitor-watchdog** и **timer** в **systemd**: раздел 6 в [docs/install-prerequisites.ru.md](docs/install-prerequisites.ru.md). + +**Ключевые темы (для поиска):** мониторинг **SSH** и **sshd**, **bash**-скрипт для **Linux**-сервера, уведомления в **Telegram** и по **SMTP** / электронной почте, **systemd-logind**, **sudo**, **journalctl**, **iptables** / **ip6tables**, автоматический **бан IP** и **whitelist**, **systemd** unit, **ежедневный отчёт**, **heartbeat**, **OpenSSH**, безопасность сервера, **Prometheus** textfile, опционально **watchdog** для сервиса. + +## Лицензия + +Проект распространяется по лицензии **MIT** — см. файл [LICENSE](LICENSE). diff --git a/contrib/hooks/pre-commit b/contrib/hooks/pre-commit new file mode 100644 index 0000000..bdb79b9 --- /dev/null +++ b/contrib/hooks/pre-commit @@ -0,0 +1,66 @@ +#!/bin/bash +# Автогенерация release/manifest-VERSION.json при bump версии (version.txt / SSH_MONITOR_VERSION). +set -euo pipefail + +ROOT="$(git rev-parse --show-toplevel)" +cd "$ROOT" + +_staged() { + git diff --cached --name-only -- "$@" +} + +_version_bump_staged() { + if _staged version.txt | grep -q .; then + return 0 + fi + if _staged ssh-monitor | grep -q .; then + if git diff --cached -- ssh-monitor | grep -qE '^[+-].*SSH_MONITOR_VERSION='; then + return 0 + fi + fi + return 1 +} + +_agent_files_staged() { + local f + for f in ssh-monitor sac-client.sh update_ssh_monitor.sh ssh-monitor-watchdog ssh-monitor-perms.sh; do + if _staged "$f" | grep -q .; then + return 0 + fi + done + return 1 +} + +_version_bump_staged || _agent_files_staged || exit 0 + +_find_python() { + local c + for c in python3 python; do + if command -v "$c" >/dev/null 2>&1 && "$c" -c 'import sys' >/dev/null 2>&1; then + echo "$c" + return 0 + fi + done + return 1 +} + +PY="$(_find_python)" || { + echo "pre-commit: python нужен для manifest (рабочий python/python3 не найден)" >&2 + exit 1 +} + +"$PY" "$ROOT/contrib/manifest/generate.py" +VER="$(tr -d '\r\n' <"$ROOT/version.txt")" +[ -n "$VER" ] || { + echo "pre-commit: version.txt пуст — не могу добавить manifest" >&2 + exit 1 +} + +MANIFEST="release/manifest-${VER}.json" +if [ ! -f "$MANIFEST" ]; then + echo "pre-commit: не создан $MANIFEST" >&2 + exit 1 +fi + +git add "$MANIFEST" +echo "pre-commit: обновлён $MANIFEST (версия $VER)" diff --git a/contrib/install-git-hooks.ps1 b/contrib/install-git-hooks.ps1 new file mode 100644 index 0000000..f247f9c --- /dev/null +++ b/contrib/install-git-hooks.ps1 @@ -0,0 +1,54 @@ +# Install git hooks + local manifest wrappers (not committed to git). +# Run from repo root: .\contrib\install-git-hooks.ps1 +$ErrorActionPreference = "Stop" + +$Root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$HookSrc = Join-Path $Root "contrib\hooks\pre-commit" +$HookDst = Join-Path $Root ".git\hooks\pre-commit" +$LocalDir = Join-Path $Root ".local" +$LocalScript = Join-Path $LocalDir "build-release-manifest.ps1" +$LocalScriptSh = Join-Path $LocalDir "build-release-manifest.sh" + +if (-not (Test-Path (Join-Path $Root ".git"))) { + throw "Not a git repository: $Root" +} + +if (-not (Test-Path $HookSrc)) { + throw "Missing hook file: $HookSrc" +} + +New-Item -ItemType Directory -Force -Path (Split-Path $HookDst) | Out-Null +Copy-Item -Force $HookSrc $HookDst + +New-Item -ItemType Directory -Force -Path $LocalDir | Out-Null + +@' +# Local manifest wrapper (gitignored). Manual generation on Windows. +$ErrorActionPreference = "Stop" +$Root = git -C "$PSScriptRoot\.." rev-parse --show-toplevel 2>$null +if (-not $Root) { + $Root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +} +$py = Get-Command python -ErrorAction SilentlyContinue +if (-not $py) { $py = Get-Command python3 -ErrorAction SilentlyContinue } +if (-not $py) { throw "python not found in PATH" } +& $py.Source (Join-Path $Root "contrib\manifest\generate.py") @args +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +'@ | Set-Content -Path $LocalScript -Encoding UTF8 + +@' +#!/bin/bash +# Local manifest wrapper (gitignored). Git Bash. +set -euo pipefail +ROOT="$(git rev-parse --show-toplevel)" +exec python3 "$ROOT/contrib/manifest/generate.py" "$@" +'@ | Set-Content -Path $LocalScriptSh -Encoding ASCII -NoNewline +Add-Content -Path $LocalScriptSh -Value "`n" -NoNewline + +Write-Host "OK: pre-commit hook -> $HookDst" +Write-Host "OK: manual script (PS) -> $LocalScript" +Write-Host "OK: manual script (sh) -> $LocalScriptSh" +Write-Host "" +Write-Host "Manual: .\.local\build-release-manifest.ps1" +Write-Host " python contrib\manifest\generate.py" +Write-Host "Pre-commit runs on version bump (python required in PATH)." diff --git a/contrib/install-git-hooks.sh b/contrib/install-git-hooks.sh new file mode 100644 index 0000000..6b1203c --- /dev/null +++ b/contrib/install-git-hooks.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Установка git hooks и локального скрипта ручной генерации manifest (не в git). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +HOOK_SRC="$ROOT/contrib/hooks/pre-commit" +HOOK_DST="$ROOT/.git/hooks/pre-commit" +LOCAL_DIR="$ROOT/.local" +LOCAL_SCRIPT="$LOCAL_DIR/build-release-manifest.sh" + +if [ ! -d "$ROOT/.git" ]; then + echo "ERROR: не git-репозиторий: $ROOT" >&2 + exit 1 +fi + +mkdir -p "$LOCAL_DIR" +cat >"$LOCAL_SCRIPT" <<'EOF' +#!/bin/bash +# Локальный wrapper (не коммитится). Ручная генерация manifest. +set -euo pipefail +ROOT="$(git rev-parse --show-toplevel)" +exec python3 "$ROOT/contrib/manifest/generate.py" "$@" +EOF +chmod +x "$LOCAL_SCRIPT" + +cp "$HOOK_SRC" "$HOOK_DST" +chmod +x "$HOOK_DST" + +echo "OK: pre-commit hook → $HOOK_DST" +echo "OK: ручной скрипт → $LOCAL_SCRIPT (в .gitignore, не уйдёт в Gitea/GitHub)" +echo "После clone: ./contrib/install-git-hooks.sh или .\\contrib\\install-git-hooks.ps1 (Windows)" diff --git a/contrib/logrotate.d/ssh-monitor b/contrib/logrotate.d/ssh-monitor new file mode 100644 index 0000000..a19cd9d --- /dev/null +++ b/contrib/logrotate.d/ssh-monitor @@ -0,0 +1,19 @@ +# Пример для /etc/logrotate.d/ssh-monitor +# Установка: sudo cp contrib/logrotate.d/ssh-monitor /etc/logrotate.d/ssh-monitor +# Пути должны совпадать с LOG_FILE и WATCHDOG_LOG_FILE в /etc/ssh-monitor.conf + +/var/log/ssh_monitor.log +/var/log/ssh_monitor_watchdog.log { + weekly + rotate 12 + compress + delaycompress + missingok + notifempty + create 0640 root root + sharedscripts + postrotate + # При необходимости отправьте сигнал процессу (если добавите обработку HUP) + /bin/true + endscript +} diff --git a/contrib/manifest/generate.py b/contrib/manifest/generate.py new file mode 100644 index 0000000..feb5a45 --- /dev/null +++ b/contrib/manifest/generate.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Генерация release/manifest-VERSION.json (SHA256 git blob = checkout на Linux).""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import subprocess +import sys +from pathlib import Path + +MANIFEST_FILES = ( + "ssh-monitor", + "sac-client.sh", + "update_ssh_monitor.sh", + "ssh-monitor-watchdog", + "ssh-monitor-perms.sh", +) + + +def repo_root() -> Path: + return Path( + subprocess.check_output(["git", "rev-parse", "--show-toplevel"], text=True).strip() + ) + + +def read_version(root: Path, explicit: str | None) -> str: + if explicit: + return explicit.strip() + vt = root / "version.txt" + if vt.is_file(): + v = vt.read_text(encoding="utf-8").strip() + if v: + return v + ssh = root / "ssh-monitor" + if ssh.is_file(): + for line in ssh.read_text(encoding="utf-8", errors="replace").splitlines(): + if line.strip().startswith("SSH_MONITOR_VERSION="): + v = line.split("=", 1)[1].strip().strip("\"'") + if v: + return v + raise SystemExit("ERROR: укажите VERSION или заполните version.txt / SSH_MONITOR_VERSION") + + +def file_bytes_from_git(root: Path, name: str) -> bytes | None: + for ref in (f":{name}", f"HEAD:{name}"): + try: + return subprocess.check_output(["git", "-C", str(root), "show", ref]) + except subprocess.CalledProcessError: + continue + return None + + +def sha256_bytes(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def sha256_release_file(root: Path, name: str) -> str: + blob = file_bytes_from_git(root, name) + if blob is not None: + return sha256_bytes(blob) + path = root / name + if not path.is_file(): + raise SystemExit(f"ERROR: нет файла {name} (ни в git index/HEAD, ни на диске)") + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def main() -> None: + parser = argparse.ArgumentParser(description="Сгенерировать release/manifest-VERSION.json") + parser.add_argument("version", nargs="?", help="Версия (по умолчанию из version.txt)") + parser.add_argument( + "--pin-commit", + action="store_true", + help="Записать git_commit=HEAD (после коммита; в pre-commit не используется)", + ) + args = parser.parse_args() + + root = repo_root() + version = read_version(root, args.version) + + for name in MANIFEST_FILES: + if file_bytes_from_git(root, name) is None and not (root / name).is_file(): + raise SystemExit(f"ERROR: нет файла {name}") + + git_commit = "" + if args.pin_commit: + try: + git_commit = subprocess.check_output( + ["git", "-C", str(root), "rev-parse", "HEAD"], text=True + ).strip() + except subprocess.CalledProcessError: + pass + + manifest = { + "version": version, + "git_commit": git_commit, + "files": {name: f"sha256:{sha256_release_file(root, name)}" for name in MANIFEST_FILES}, + } + + out = root / "release" / f"manifest-{version}.json" + out.parent.mkdir(parents=True, exist_ok=True) + with out.open("w", encoding="utf-8") as f: + json.dump(manifest, f, indent=2, ensure_ascii=False) + f.write("\n") + + pin = git_commit[:12] if git_commit else "(empty)" + print(f"Wrote {out.relative_to(root)} git_commit={pin}") + + +if __name__ == "__main__": + main() diff --git a/contrib/test-config-parser.sh b/contrib/test-config-parser.sh new file mode 100644 index 0000000..7f512df --- /dev/null +++ b/contrib/test-config-parser.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# Smoke-test safe config parser (no network). Run from repo root. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=ssh-monitor-perms.sh +source "$ROOT/ssh-monitor-perms.sh" + +tmpdir="$(mktemp -d)" +trap 'rm -rf "$tmpdir"' EXIT +cfg="$tmpdir/test.conf" + +cat >"$cfg" <<'EOF' +# comment +TELEGRAM_BOT_TOKEN="tok" +UseSAC="exclusive" +SAC_URL="https://sac.example.test" +WHITELIST_IPS="127.0.0.1,10.0.0.5" +WHITELIST_SUBNETS="10.0.0.0/24" +BACKUP_WEBHOOK_URL="https://hooks.example/legacy" +UNKNOWN_KEY="ignored" +EOF + +ssh_monitor_config_load_file "$cfg" + +[ "${TELEGRAM_BOT_TOKEN:-}" = "tok" ] || { + echo "FAIL: TELEGRAM_BOT_TOKEN" >&2 + exit 1 +} +[ "${UseSAC:-}" = "exclusive" ] || { + echo "FAIL: UseSAC" >&2 + exit 1 +} + +ips_csv="${WHITELIST_IPS:-}" +subs_csv="${WHITELIST_SUBNETS:-}" +WHITELIST_IPS=() +WHITELIST_SUBNETS=() +IFS=',' read -r -a WHITELIST_IPS <<<"$ips_csv" +IFS=',' read -r -a WHITELIST_SUBNETS <<<"$subs_csv" + +ssh_monitor_validate_config_values || { + echo "FAIL: validate_config_values" >&2 + exit 1 +} + +printf 'EVIL="$(id)"\n' >"$tmpdir/bad.conf" +if ssh_monitor_config_load_file "$tmpdir/bad.conf" 2>/dev/null; then + +echo "OK: config parser smoke test passed" diff --git a/contrib/test-sac-fail-count.sh b/contrib/test-sac-fail-count.sh new file mode 100644 index 0000000..aee334a --- /dev/null +++ b/contrib/test-sac-fail-count.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Smoke-test sac-fail.count suppress on shutdown flag. Run from repo root. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +tmpdir="$(mktemp -d)" +trap 'rm -rf "$tmpdir"' EXIT + +write_log() { :; } +ssh_monitor_secure_file() { :; } +ssh_monitor_secure_dir() { mkdir -p "$1"; } + +# shellcheck source=sac-client.sh +source "$ROOT/sac-client.sh" + +export SAC_FAIL_COUNT_FILE="${tmpdir}/sac-fail.count" + +sac_write_fail_count 0 +sac_increment_fail_count +[ "$(sac_read_fail_count)" = "1" ] || { + echo "FAIL: expected count 1 after increment" >&2 + exit 1 +} + +SAC_SUPPRESS_FAIL_COUNT=1 +sac_increment_fail_count || true +[ "$(sac_read_fail_count)" = "1" ] || { + echo "FAIL: count changed under SAC_SUPPRESS_FAIL_COUNT" >&2 + exit 1 +} + +echo "OK sac-fail.count suppress" diff --git a/docs/auto-update.ru.md b/docs/auto-update.ru.md new file mode 100644 index 0000000..a6ffb47 --- /dev/null +++ b/docs/auto-update.ru.md @@ -0,0 +1,173 @@ +# Автообновление ssh-monitor + +Скрипт **`update_ssh_monitor.sh`** обновляет **`/usr/local/bin/ssh-monitor`** из git и перезапускает systemd-сервис. + +## Основной путь: Security Alert Center + +На продакшен-хостах обновление идёт **через SAC**, без cron: + +1. Админ нажимает **«Обновить ssh-monitor (SSH)»** в карточке хоста. +2. SAC по SSH запускает `/opt/scripts/update_ssh_monitor.sh` с `REPO_URL` / `GIT_BRANCH` из **Настройки → Обновления агентов**. +3. Updater сам выполняет `git pull`, синхронизирует `sac-client.sh` и watchdog, дописывает отсутствующие ключи в **`/etc/ssh-monitor.conf`**, **выставляет `chmod`/`chown`** на конфиг, SAC-spool, счётчики и state-файлы, перезапускает **`ssh-monitor.service`**. + +Участие админа на сервере **не требуется** (кроме первичной настройки `SAC_URL` / `SAC_API_KEY` в конфиге). + +При **первой установке** (нет updater на хосте) SAC выполняет bootstrap с **`--deploy`**: clone, ipset при необходимости, systemd unit, полный hardening. + +**Cron / systemd timer** — опциональная альтернатива с тем же скриптом и обязательным `REPO_URL` в окружении. + +Watchdog и таймеры watchdog **не изменяются** при обычном update (кроме синхронизации скрипта watchdog из git). + +**Конфиг `/etc/ssh-monitor.conf`:** при первой установке копируется из `ssh-monitor.conf.example`. При каждом update в **существующий** конфиг **дописываются** отсутствующие SAC-переменные и ключи безопасности; **`UseSAC=fallback`**. Telegram/SMTP и прочие ключи **не перезаписываются**. + +Установка монитора и unit-файлов: [README.md](../README.md), зависимости: [install-prerequisites.ru.md](install-prerequisites.ru.md). + +## Что делает скрипт + +1. Создаёт рабочий каталог **`UPDATE_DIR`** (по умолчанию `/opt/scripts/update`). +2. При **`--deploy`** / bootstrap SAC проверяет **`ipset`** и при отсутствии пытается установить через пакетный менеджер. Обычное обновление **не** вызывает `apt-get`. +3. В **`UPDATE_DIR`** выполняет **`git clone`** или **`git fetch` + checkout / `git pull --ff-only`** (без слепого `reset --hard`). +4. Проверяет **`release/manifest-VERSION.json`** (SHA256 файлов + commit) **до** копирования в `/usr/local/bin`. +5. Ищет **`ssh-monitor`** в клоне (GitLab layout или плоский репозиторий). +6. Сравнивает **SHA256** с **`LOCAL_SCRIPT_PATH`** (`/usr/local/bin/ssh-monitor`). +7. **Всегда** синхронизирует **`sac-client.sh`**, **`ssh-monitor-perms.sh`**, watchdog. +8. Дописывает ключи конфига; **`apply_runtime_security_hardening`**: `chmod 600` конфиг, `700` spool, `600` state/SAC-файлы, whitelist. +9. При изменении **`ssh-monitor`**, **`sac-client.sh`** или конфига — **`systemctl restart ssh-monitor.service`**. +10. Лог: **`/var/log/update_script.log`** (блок «Итог обновления», строки `manifest` и `security hardening`). + +```mermaid +flowchart LR + A[SAC или cron] --> B[update_ssh_monitor.sh] + B --> C[git fetch/checkout] + C --> M{manifest OK?} + M -->|нет| X[exit 1] + M -->|да/skip| D[sync sac-client + perms lib] + C --> E{SHA256 ssh-monitor?} + D --> F[harden config/spool/state] + E -->|да| G[backup + cp ssh-monitor] + E -->|нет| F + G --> F + F --> H{что-то изменилось?} + H -->|да| I[restart ssh-monitor.service] + H -->|нет| J[без перезапуска] +``` + +## Настройка перед первым запуском + +| Переменная | По умолчанию | Назначение | +|------------|--------------|------------| +| `UPDATE_DIR` | `/opt/scripts/update` | Каталог для git-клона | +| `REPO_URL` | *(обязательно)* | URL git — задаётся SAC, export или `Environment=` в cron | +| `GIT_BRANCH` | `main` | Ветка для clone/pull, если **`GIT_REF`** не задан | +| `GIT_REF` | *(пусто)* | Тег (`2.2.0-SAC`), ветка или commit SHA для checkout | +| `GIT_VERIFY_MODE` | `off` | `off` \| `tag` \| `commit` — см. раздел ниже | +| `GIT_ALLOW_RESET` | `0` | `1` = разрешить `git reset --hard` при расхождении истории (риск) | +| `RELEASE_MANIFEST_STRICT` | `0` | `1` = exit 1, если manifest для версии не найден | +| `LOCAL_SCRIPT_PATH` | `/usr/local/bin/ssh-monitor` | Куда копировать монитор | +| `LOG_FILE` | `/var/log/update_script.log` | Лог обновлений | +| `UPDATE_VIA_SAC` | *(опционально)* | SAC выставляет `1`; при отсутствии бинарника включается `--deploy` | + +Требования: **root**, **git**, **python3** (manifest verify), доступ к репозиторию. + +## Pinned ref и manifest (2.2.0+) + +Updater читает **`GIT_REF`**, **`GIT_VERIFY_MODE`**, **`GIT_ALLOW_RESET`** из env или `/etc/ssh-monitor.conf`. + +### Режимы `GIT_VERIFY_MODE` + +| Режим | Поведение | Когда | +|-------|-----------|-------| +| **`off`** | После fetch принять ref как есть | Тест, legacy; дефолт в скрипте | +| **`tag`** | `GIT_REF` должен существовать как тег | **Рекомендуется для прода** | +| **`commit`** | `GIT_REF` = полный SHA commit | Жёсткий pin без тегов | + +Если **`GIT_REF`** пуст, используется **`GIT_BRANCH`** (`git pull --ff-only`). При невозможности fast-forward updater **не** делает `reset --hard` (только с **`GIT_ALLOW_RESET=1`**). + +### Release manifest + +В репозитории: **`release/manifest-VERSION.json`** — SHA256 ключевых файлов и `git_commit`. Updater сверяет manifest **после** git sync и **до** копирования бинарников. + +- Файл найден и hash не совпал → **exit 1**, ничего не перезаписывается. +- Файл не найден → WARN, update продолжается (или exit 1 при **`RELEASE_MANIFEST_STRICT=1`**). +- Копирование **`update_ssh_monitor.sh`** в `/opt/scripts/` и re-exec — только если manifest для версии был и прошёл verify. + +Генерация manifest — см. [release-manifest.ru.md](release-manifest.ru.md). После `./contrib/install-git-hooks.sh` manifest обновляется **автоматически** при bump версии в pre-commit. Ручной wrapper: `.local/build-release-manifest.sh` (не в git). + +```bash +./contrib/install-git-hooks.sh # один раз после clone +# bump version.txt + ssh-monitor → git commit (hook добавит manifest) +git tag 2.2.3-SAC +``` + +### Примеры для прода + +**SAC / env на хосте** (через настройки SAC или unit): + +```bash +REPO_URL=https://git.papatramp.ru/PapaTramp/ssh-monitor.git +GIT_REF=2.2.0-SAC +GIT_VERIFY_MODE=tag +``` + +**systemd timer** (`/etc/systemd/system/ssh-monitor-update.service`): + +```ini +[Service] +Type=oneshot +Environment=REPO_URL=https://git.papatramp.ru/PapaTramp/ssh-monitor.git +Environment=GIT_REF=2.2.0-SAC +Environment=GIT_VERIFY_MODE=tag +ExecStart=/opt/scripts/update_ssh_monitor.sh +``` + +При доверенном закрытом зеркале **manifest + `GIT_REF=tag`** — достаточный минимум; GPG-подписи тегов — backlog (см. [security-roadmap.ru.md](security-roadmap.ru.md)). + +## Первичная установка (--deploy) + +```bash +sudo REPO_URL=https://git.papatramp.ru/PapaTramp/ssh-monitor.git ./update_ssh_monitor.sh --deploy +# или first_deploy.sh с REPO_URL в окружении +``` + +Через SAC bootstrap выполняется автоматически с **`--deploy`**. + +## Установка updater (если не через SAC bootstrap) + +```bash +sudo mkdir -p /opt/scripts +sudo cp update_ssh_monitor.sh /opt/scripts/update_ssh_monitor.sh +sudo chmod 750 /opt/scripts/update_ssh_monitor.sh +``` + +Проверка: + +```bash +sudo REPO_URL=https://git.papatramp.ru/PapaTramp/ssh-monitor.git /opt/scripts/update_ssh_monitor.sh +sudo tail -30 /var/log/update_script.log +``` + +## Запуск по расписанию (опционально) + +### Cron + +``` +0 4 * * * REPO_URL=https://git.papatramp.ru/PapaTramp/ssh-monitor.git /opt/scripts/update_ssh_monitor.sh +``` + +### systemd timer + +См. предыдущие примеры `ssh-monitor-update.service` / `.timer` с `Environment=REPO_URL=...`. + +## Откат + +```bash +ls -la /usr/local/bin/ssh-monitor.backup.* +sudo cp /usr/local/bin/ssh-monitor.backup.YYYYMMDD_HHMMSS /usr/local/bin/ssh-monitor +sudo systemctl restart ssh-monitor.service +``` + +## Ограничения + +- Секреты (`SAC_API_KEY`, Telegram) SAC **не** прописывает — только в `/etc/ssh-monitor.conf` на хосте. +- При недоступном git **`git pull`** пишет предупреждение и продолжает с локальной копией. +- **`REPO_URL`** без дефолта в скрипте — только доверенное зеркало (kalinamall / своё Gitea). diff --git a/docs/install-prerequisites.ru.md b/docs/install-prerequisites.ru.md new file mode 100644 index 0000000..05197b9 --- /dev/null +++ b/docs/install-prerequisites.ru.md @@ -0,0 +1,152 @@ +# Системные зависимости и установка окружения + +Этот документ описывает, **какой софт** на сервере нужен для работы **ssh-monitor**, и **как его поставить** типовыми пакетами. Саму установку скрипта, конфигов и **systemd** смотрите в корневом [README.md](../README.md). + +## 1. Что должно быть на хосте + +### Обязательно + +| Назначение | Что используется | Примечание | +|------------|------------------|------------| +| Интерпретатор | **Bash** (версия с `declare -A`) | Обычно уже есть (`/bin/bash`). | +| Запуск брандмауэра | **iptables**; для IPv6 — **ip6tables** (если баните IPv6) | Скрипт добавляет правила `DROP` для заблокированных IP. Нужен **root** (скрипт не стартует без root, кроме `--dry-run` / проверок). | +| Telegram | **curl** | Запросы к `api.telegram.org`. | +| Почта (канал `email`) | **Python 3** | SMTP через стандартную библиотеку. | +| Резервный webhook | **Python 3** + **curl** | Сборка JSON и `POST`. | + +Без **хотя бы одного** настроенного канала (`telegram` или `email`) скрипт завершится с ошибкой — это не зависимости ОС, а настройка в `/etc/ssh-monitor.conf`. + +### Настоятельно рекомендуется (полный функционал) + +| Назначение | Что используется | +|------------|------------------| +| Журнал SSH/sudo/брутфорс, logind | **systemd** + **`journalctl`** | +| Дедупликация уведомлений logind для SSH | **`loginctl`** (часть **systemd**) | +| Удобный просмотр логов сервиса | **`journalctl`** | + +На минималистичных системах без **journald** часть функций не работает или упрощается; отчёт за сутки по SSH может опираться на **`/var/log/auth.log`** (см. ниже). + +### Утилиты из «базовой» системы + +Обычно уже установлены: `grep`, `sed`, `awk`, `sort`, `date`, `who`, `mktemp`, `sudo` (если запускаете не напрямую от root), `wc`, `hostname`, `ip` (для подписи сервера в уведомлениях). Для дедупликации sudo в журнале желательны **`sha256sum`** или **`cksum`**. + +--- + +## 2. Пошаговая установка пакетов (Debian / Ubuntu) + +Выполняйте от пользователя с `sudo`. + +**Шаг 1.** Обновить индексы пакетов: + +```bash +sudo apt update +``` + +**Шаг 2.** Минимум для Telegram и бана по firewall: + +```bash +sudo apt install -y iptables curl +``` + +При необходимости IPv6 (если на сервере используется `ip6tables`): + +```bash +sudo apt install -y ip6tables +``` + +(На многих образах **ip6tables** уже входит в метапакет с iptables.) + +**Шаг 3.** Если включите доставку почты (`MAIL_*` в конфиге): + +```bash +sudo apt install -y python3 +``` + +**Шаг 4.** Типовой сервер с **systemd** уже содержит `journalctl` и `loginctl`. Если ставите минимальный контейнер без них — для полного мониторинга нужен стек с **systemd** и **journald** (или см. раздел про ограничения без journald). + +**Шаг 5.** (Опционально) Сохранение правил **iptables** после перезагрузки — отдельно от ssh-monitor, например: + +```bash +sudo apt install -y iptables-persistent netfilter-persistent +``` + +Скрипт при наличии каталога **`/etc/iptables`** может записывать туда `rules.v4` / `rules.v6` (см. код `save_iptables_rules`). Имеет смысл создать каталог и настроить автозагрузку правил по документации вашего дистрибутива. + +--- + +## 3. Другие дистрибутивы + +Принцип тот же: пакеты **bash**, **iptables** (+ **ip6tables**), **curl**, **python3**; для полного функционала — **systemd** с **journald**. + +Примеры: + +- **RHEL / Alma / Rocky:** `dnf install iptables curl python3 systemd` (имена метапакетов могут отличаться). +- **Alpine:** `apk add bash iptables ip6tables curl python3` — отдельно проверьте наличие **systemd** / **journalctl** (на Alpine часто нет; мониторинг по journal будет недоступен). + +--- + +## 4. Системы без `journalctl` (классический syslog) + +- События **SSH** могут читаться из **`/var/log/auth.log`** (если скрипт не находит `journalctl`). +- **Ежедневная статистика** по этим файлам для отчёта **точнее с Python 3** (разбор логов в скрипте). Без Python отчёт может использовать упрощённый fallback. +- Блоки, завязанные на **`journalctl`** (**sudo** по журналу, **systemd-logind**, часть **security/brute**), на таком хосте **не дадут полного результата**. + +--- + +## 5. Конфликты с UFW / другими обёртками firewall + +Скрипт вставляет правила **iptables** самостоятельно. Если используете **UFW**, **firewalld** или только **nftables** без совместимости с iptables-nft, возможны конфликты или неожиданный порядок правил. После внедрения проверьте: + +```bash +sudo iptables -L INPUT -n -v --line-numbers +``` + +При необходимости согласуйте порядок с вашей схемой управления firewall. + +--- + +## 6. Опционально: watchdog + +Скрипт **`ssh-monitor-watchdog`** проверяет сервис `ssh-monitor.service` и актуальность heartbeat; при сбое выполняет `systemctl restart`. + +**Установка:** + +1. Скопируйте скрипт: + `sudo install -m 750 ./ssh-monitor-watchdog /usr/local/bin/ssh-monitor-watchdog` +2. Установите unit и timer: + `sudo cp ./ssh-monitor-watchdog.service.example /etc/systemd/system/ssh-monitor-watchdog.service` + `sudo cp ./ssh-monitor-watchdog.timer.example /etc/systemd/system/ssh-monitor-watchdog.timer` +3. Включите timer: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable ssh-monitor-watchdog.timer +sudo systemctl start ssh-monitor-watchdog.timer +``` + +4. Проверка: + +```bash +sudo systemctl status ssh-monitor-watchdog.timer +sudo systemctl list-timers | grep ssh-monitor-watchdog +sudo journalctl -u ssh-monitor-watchdog.service -f +``` + +Параметры watchdog задаются в **`/etc/ssh-monitor.conf`** (переменные `WATCHDOG_*` в примере конфига). + +--- + +## 7. Краткий чек-лист перед первым запуском + +- [ ] Установлены **iptables** (и при необходимости **ip6tables**). +- [ ] Для Telegram установлен **curl**; для почты / webhook — **python3**. +- [ ] Настроен **`/etc/ssh-monitor.conf`** (минимум один канал уведомлений). +- [ ] Понятен порядок правил firewall на хосте (UFW и т.д.). +- [ ] (По желанию) настроено сохранение правил после перезагрузки. + +Далее: установка самого **`ssh-monitor`**, unit **systemd** и проверка — в [README.md](../README.md). + +Дополнительно: + +- [notifications.ru.md](notifications.ru.md) — подпись **«🖥️ Сервер»** в уведомлениях, типы алертов, часовые пояса. +- [auto-update.ru.md](auto-update.ru.md) — автообновление скрипта через **`update_ssh_monitor.sh`**. diff --git a/docs/notifications.ru.md b/docs/notifications.ru.md new file mode 100644 index 0000000..a89b4e6 --- /dev/null +++ b/docs/notifications.ru.md @@ -0,0 +1,97 @@ +# Уведомления ssh-monitor + +Документ описывает, **какие сообщения** отправляет монитор, **какие поля** в них есть и как настроить **подпись сервера** и **часовой пояс**. + +См. также: [README.md](../README.md), пример конфига [ssh-monitor.conf.example](../ssh-monitor.conf.example). + +## Подпись сервера (🖥️ Сервер) + +Начиная с версии **1.1.3-server-label**, каждое уведомление, проходящее через **`notify_send()`**, получает строку **«🖥️ Сервер: …»** сразу **после первой строки** (заголовка алерта). + +Формат значения: + +| Условие | Пример | +|---------|--------| +| `SERVER_DISPLAY_NAME` пусто, IPv4 найден | `web01 (10.1.20.5)` | +| `SERVER_DISPLAY_NAME` пусто, IPv4 нет | `web01` | +| Задан `SERVER_DISPLAY_NAME="prod-db-01"` | `prod-db-01 (10.1.20.5)` | + +IPv4 определяется через `ip -4 route get` (исходящий адрес) или `hostname -I`. Нужны утилиты **`hostname`** и **`ip`** — см. [install-prerequisites.ru.md](install-prerequisites.ru.md). + +Если в тексте сообщения уже есть подстрока **`🖥️ Сервер:`**, вторая строка **не добавляется** (защита от дубля при ручной вставке). + +### Пример: успешный SSH + +``` +✅ УСПЕШНОЕ SSH ПОДКЛЮЧЕНИЕ +🖥️ Сервер: myhost (10.1.20.5) +👤 Пользователь: alice +🌐 IP адрес: 10.1.20.1 +🕐 Время: 25.05.2026 09:08:22 +``` + +### Пример: sudo + +``` +⚠️ ИСПОЛЬЗОВАНИЕ SUDO +🖥️ Сервер: myhost (10.1.20.5) +👤 Пользователь: alice +🔑 От имени USER: root +💻 Команда: /bin/bash +📁 Директория: /home/alice +🕐 Время: 24.05.2026 16:54:49 +``` + +## Настройка в `/etc/ssh-monitor.conf` + +```bash +# Пусто = hostname (+ IPv4 при возможности) +SERVER_DISPLAY_NAME="" + +# Или фиксированное имя для этого хоста в общем чате: +# SERVER_DISPLAY_NAME="dc1-app-03" +``` + +Проверка эффективной подписи: + +```bash +sudo ssh-monitor --check-config +``` + +В выводе будет строка `SERVER_DISPLAY_NAME=... (эффективно: ...)`. + +## Типы уведомлений + +| Событие | Заголовок (первая строка) | Доп. поля | +|---------|----------------------------|-----------| +| Успешный SSH | ✅ УСПЕШНОЕ SSH ПОДКЛЮЧЕНИЕ | пользователь, IP клиента, время | +| SSH под root | 🔑 SSH ВХОД ПОД ROOT | IP, время | +| Неудачная попытка SSH | ❌ НЕУДАЧНАЯ ПОПЫТКА SSH | пользователь, IP, счётчик попыток, время | +| Sudo | ⚠️ ИСПОЛЬЗОВАНИЕ SUDO | пользователь, USER=, команда, PWD, время | +| Бан IP | 🚫 IP ЗАБЛОКИРОВАН АВТОМАТИЧЕСКИ | IP, попытки, длительность, время | +| Порог без бана | ⚠️ ЛИМИТ НЕУДАЧНЫХ SSH (автобан отключён…) | IP, попытки, время | +| Брутфорс | 🧨 ВОЗМОЖНЫЙ МАССОВЫЙ БРУТФОРС SSH | IP, счётчик за окно, время | +| Host key / MITM | 🔐 ВНИМАНИЕ: изменение SSH host key… | фрагмент журнала, время | +| logind: новая сессия | 🖥️ НОВАЯ СЕССИЯ (systemd-logind) | пользователь, ID сессии, время | +| logind: сбой | ❌ СБОЙ (systemd-logind) | строка журнала, время | +| Ежедневный отчёт | 📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH МОНИТОРИНГА | статистика 24 ч, баны, топ IP, сессии | +| Heartbeat | ❤️ Heartbeat - скрипт мониторинга работает | время | +| Старт / стоп | ✅ СКРИПТ МОНИТОРИНГА ЗАПУЩЕН / ⚠️ ОСТАНОВЛЕН | версия, каналы, время | + +Во всех перечисленных случаях **🖥️ Сервер** добавляется автоматически (кроме сообщений, где вы сами вставили эту строку). + +## Время в сообщениях (🕐) + +Строки **«🕐 Время»** формируются функцией **`notification_date()`**: + +1. Если задан **`NOTIFY_TZ`** (IANA, например `Europe/Moscow`) — используется он. +2. Иначе, если задан **`DAILY_REPORT_TZ`** — он. +3. Иначе — зона процесса (`date` у systemd-сервиса; часто UTC, если в unit указано `Environment=TZ=UTC`). + +Ежедневный отчёт по календарю и часу **`DAILY_REPORT_HOUR`** использует **`DAILY_REPORT_TZ`** (или зону процесса) — см. README. + +## Каналы доставки + +- **`NOTIFY_CHAIN`**: `telegram`, `email` — при каждом событии попытка **во все** каналы списка. + +Режим **`--dry-run`**: уведомления не отправляются; в stderr печатается текст **уже с подписью сервера**. diff --git a/docs/release-manifest.ru.md b/docs/release-manifest.ru.md new file mode 100644 index 0000000..cf6cded --- /dev/null +++ b/docs/release-manifest.ru.md @@ -0,0 +1,46 @@ +# Release manifest + +Файлы `release/manifest-VERSION.json` — SHA256 агентских скриптов **как в git** (после `checkout` на Linux). Updater сверяет их до копирования на хост. + +## Автоматически (рекомендуется) + +Один раз после clone: + +**Linux / Git Bash:** +```bash +./contrib/install-git-hooks.sh +``` + +**Windows (PowerShell):** +```powershell +.\contrib\install-git-hooks.ps1 +``` + +При коммите, если изменились **`version.txt`**, **`SSH_MONITOR_VERSION`** в `ssh-monitor` или любой из агентских файлов (`ssh-monitor`, `*.sh` из списка manifest), **pre-commit** вызовет `contrib/manifest/generate.py` и добавит `release/manifest-{версия}.json` в коммит. + +## Ручная генерация (локально) + +Скрипты **`.local/build-release-manifest.ps1`** (Windows) и **`.local/build-release-manifest.sh`** (Git Bash) создаются установщиком и **не коммитятся** (см. `.gitignore`). + +```powershell +.\.local\build-release-manifest.ps1 +.\.local\build-release-manifest.ps1 2.2.3-SAC +``` + +```bash +./.local/build-release-manifest.sh +``` + +Ядро генерации в репозитории: `contrib/manifest/generate.py` (используется hook и локальным wrapper). + +## Релиз (чек-лист) + +1. Bump `SSH_MONITOR_VERSION` + `version.txt` +2. `git add` изменённые файлы +3. `git commit` — hook обновит manifest (или вручную `.local/build-release-manifest.sh`) +4. `git tag X.Y.Z-SAC` +5. Push в kalinamall (+ GitHub при необходимости) + +Поле `git_commit` в manifest по умолчанию **пустое** (проверяются только SHA256 файлов). Опционально после коммита: `python3 contrib/manifest/generate.py --pin-commit`. + +См. также [auto-update.ru.md](auto-update.ru.md). diff --git a/docs/sac-ingest.ru.md b/docs/sac-ingest.ru.md new file mode 100644 index 0000000..8425893 --- /dev/null +++ b/docs/sac-ingest.ru.md @@ -0,0 +1,40 @@ +# SAC ingest: таймауты и fallback + +Документ для эксплуатации **ssh-monitor** с `UseSAC=fallback` или `exclusive` при нагрузке на SAC (массовый update агентов, deploy backend). + +См. также: [ingest-mass-update-backlog.ru.md](https://git.papatramp.ru/PapaTramp/security-alert-center/src/branch/main/docs/ingest-mass-update-backlog.ru.md) (SAC + ops). + +## `SAC_TIMEOUT_SEC` + +Таймаут **connect** и **max-time** для `curl` к SAC (`/health`, `POST /api/v1/events`). + +| Сценарий | Рекомендация | +|----------|--------------| +| Обычная нагрузка | **45** (дефолт в `ssh-monitor.conf.example` и при миграции с 12) | +| Массовый restart агентов, тяжёлый ingest на SAC | **60–90** на время окна обслуживания | +| SAC на том же хосте перезапускается (`sac-deploy`) | Кратковременные отказы POST — норма; не поднимать таймаут «навсегда» без причины | + +На стороне SAC (nginx) для ingest задан `proxy_read_timeout 120s` на `POST /api/v1/events`. Агентский таймаут **не обязан** равняться 120: достаточно, чтобы POST не обрывался раньше типичного ответа API. При `SAC_TIMEOUT_SEC` меньше nginx-лимита агент уйдёт в spool и `sac-fail.count`, а не «висит» минутами. + +Проверка после смены: + +```bash +sudo ssh-monitor --check-config +sudo ssh-monitor --check-sac +``` + +## `sac-fail.count` и режим `fallback` + +При ошибке POST ingest счётчик в `SAC_FAIL_COUNT_FILE` увеличивается. После **`SAC_FALLBACK_FAILURES`** подряд (по умолчанию 5) агент перестаёт слать в SAC и шлёт только в локальный Telegram, пока `/health` снова не станет OK. + +**Сброс счётчика:** любой успешный POST (в т.ч. heartbeat) обнуляет `sac-fail.count`. + +**Shutdown / SIGTERM (с 2.3.2-SAC):** при остановке монитора (`systemctl stop`, restart во время SAC-update) неудачный lifecycle POST **не увеличивает** счётчик — иначе после штатного restart пачки хостов агент ложно уходит в Telegram-fallback. + +Lifecycle при SAC-update по-прежнему **пропускается**, если существует state-file `agent-update-in-progress` (см. `update_ssh_monitor.sh`). + +## Операционные правила + +1. Не совмещать `sudo /opt/sac-deploy.sh` и массовое «Обновить ssh-monitor» по многим хостам в одно окно. +2. Обновлять Linux-хосты **пачками по 2–3**. +3. На зрелых хостах — **`UseSAC=exclusive`**, если локальный Telegram при fallback не нужен. diff --git a/docs/security-roadmap.ru.md b/docs/security-roadmap.ru.md new file mode 100644 index 0000000..acd7136 --- /dev/null +++ b/docs/security-roadmap.ru.md @@ -0,0 +1,261 @@ +# Security roadmap — ssh-monitor + +Чек-лист правок по аудиту безопасности. **Код пока не трогаем** — документ для совместного просмотра и утверждения фаз. + +Связанные файлы: `ssh-monitor`, `update_ssh_monitor.sh`, `sac-client.sh`, `ssh-monitor-watchdog`, `ssh-monitor.conf.example`. + +--- + +## Модель доверия (согласовано) + +| Объект | Кто отвечает | +|--------|----------------| +| **`REPO_URL`** | Оператор. Указывает **своё** зеркало (kalinamall, GitHub после санитайза, форк). Жёсткий allowlist в коде **не нужен**. | +| **Закрытое зеркало** | Доверенный git в LAN/VPN — основной путь обновления. | +| **Публичный GitHub** | Санитизированная копия без секретов; обновление оттуда допустимо, если оператор сам прописал URL. | +| **`/etc/ssh-monitor.conf`** | Доверенный root-only файл; агент работает от root. | +| **Аудиты** | Нерегулярно; roadmap закрывает разумный baseline, не «вечный SOC». | + +--- + +## Порядок релизов + +| Версия | Фазы | Суть | +|--------|------|------| +| **2.1.7-SAC** | Фаза 1 + выбранное из Фазы 4 | Права, `REPO_URL` обязателен, webhook, docs | ✅ реализовано | +| **2.1.8-SAC** | Дополнение к 1.1 | SAC-first update: hardening прав на каждом прогоне updater | ✅ реализовано | +| **2.1.9-SAC** | Дополнение к 1.x | Watchdog: `🖥️ Сервер:` в Telegram (как у агента) | ✅ реализовано | +| **2.3.2-SAC** | **Фаза 4.2** | Shutdown: не крутить `sac-fail.count`; docs `SAC_TIMEOUT_SEC` | ✅ реализовано | +| **2.3.1-SAC** | Hotfix | `--check-config` + whitelist IPv4 | ✅ реализовано | +| **2.2.3-SAC** | Дополнение к 2.2.x | Подавление sudo TG при SAC bootstrap/update | ✅ реализовано | +| **2.2.1-SAC** | Дополнение к 2.2.0 | Подавление lifecycle/watchdog TG при SAC-update | ✅ реализовано | +| **2.2.0-SAC** | **Фаза 2** | Manifest, pinned ref, без слепого `reset --hard` | ✅ реализовано | +| **2.3.0-SAC** | Фаза 3 | Парсер конфига без `source` (minor breaking) | + +Bump: `ssh-monitor` (`SSH_MONITOR_VERSION`) + `version.txt` в каждом релизе. + +**Текущий статус (2026-07-08):** фаза **4.2** закрыта (2.3.2-SAC). Дальше — ops (`UseSAC=exclusive`, пачки update) и опционально GPG / `GIT_REF=tag` на проде. +Backlog по ingest при массовом update (SAC + ops): [ingest-mass-update-backlog.ru.md](https://git.papatramp.ru/PapaTramp/security-alert-center/src/branch/main/docs/ingest-mass-update-backlog.ru.md) (отдельный ToDo). + +--- + +## Фаза 1 — 2.1.7-SAC + +### 1.1 Права на state / spool (M4) + +- [x] При создании каталогов и файлов состояния: `chown root:root`, каталоги `chmod 700`, файлы `chmod 600` +- [x] Затронуть: `SAC_SPOOL_DIR`, `SAC_FAIL_COUNT_FILE`, heartbeat / last_* (где создаёт `ssh-monitor` / `sac-client.sh` / deploy) +- [x] **2.1.8:** `update_ssh_monitor.sh` → `apply_runtime_security_hardening` на **каждом** update (SAC, cron, вручную): retrofit `chmod`/`chown` существующих путей из конфига +- [x] В `--check-config`: предупреждение, если существующие пути с ослабленными правами + +**Файлы:** `ssh-monitor`, `sac-client.sh`, при необходимости `update_ssh_monitor.sh` + +--- + +### 1.2 Проверка прав конфига при старте (C1, частично) + +- [x] Перед загрузкой конфига: если `/etc/ssh-monitor.conf` существует — проверить `root:root`, mode `600` или `400` +- [x] **Поведение по умолчанию:** `WARN` в лог + stderr, работа продолжается (не ломать старые установки с `644`) +- [x] Опционально в конфиге: `CONFIG_STRICT_PERMS=1` → **exit 1** при нарушении + +**Решение агента:** strict выключен по умолчанию; в README рекомендовать `chmod 600` и позже `400`. + +--- + +### 1.3 `BACKUP_WEBHOOK_URL` (H3) — **вариант A (soft-deprecate)** + +- [x] В `ssh-monitor.conf.example`: закомментировать / убрать из «активного» блока, комментарий `DEPRECATED: не используется в типовом деплое; будет удалён в 2.3.x` +- [x] В README / `docs/notifications.ru.md`: пометить **legacy / необязательно** +- [x] В `--check-config`: если задан — WARN (удалён в 2.3.0) +- [x] **Удаление кода** — **2.3.0-SAC** + +--- + +### 1.4 `REPO_URL` — только из env, без дефолта в коде (C3) — **утверждено** + +- [x] Убрать захардкоженный default `https://git.kalinamall.ru/...` из `update_ssh_monitor.sh` +- [x] При старте updater: если `REPO_URL` пуст — **exit 1**, сообщение в **stderr** и **`$LOG_FILE`** +- [x] Текст ошибки: что задать (`export REPO_URL=...` или в systemd unit `Environment=REPO_URL=...`) +- [x] SAC: основной путь обновления (кнопка в UI); cron/timer — опционально. Bootstrap SAC → `--deploy` +- [x] **Не** делать allowlist доменов — оператор сам выбирает зеркало + +**Файлы:** `update_ssh_monitor.sh`, `docs/auto-update.ru.md`, пример unit/timer если есть + +**Заметка:** после 2.1.7 на всех хостах нужно явно прописать `REPO_URL` до следующего обновления. + +--- + +### 1.5 Документация threat model + +- [x] `README.md` + этот файл: root-агент, доверие к `REPO_URL` и конфигу, санитайз GitHub +- [x] Рекомендация: критичные хосты — только закрытое зеркало; автообновление осознанно + +--- + +## Фаза 2 — 2.2.0-SAC + +### 2.1 Pinned ref и режим верификации (C2) — **утверждено, детали на агенте** + +Новые переменные (env или ключи в `/etc/ssh-monitor.conf`, читаемые updater’ом): + +| Переменная | Назначение | Рекомендация | +|------------|------------|--------------| +| **`GIT_REF`** | Что checkout: тег (`2.2.0-SAC`), ветка (`main`) или commit SHA | **Тег релиза** — оптимально для прода | +| **`GIT_VERIFY_MODE`** | `off` \| `tag` \| `commit` | См. ниже | + +**`GIT_VERIFY_MODE`:** + +| Значение | Поведение | Когда использовать | +|----------|-----------|-------------------| +| **`off`** | После fetch принять ref как есть (как сейчас, но без `reset --hard` по умолчанию) | Тест, если manifest ниже достаточен | +| **`tag`** | `GIT_REF` должен быть **аннотированным тегом**; опционально `git tag -v` (см. 2.4) | **Рекомендуется для прода** | +| **`commit`** | `GIT_REF` = полный SHA; сверка с manifest | Жёсткий pin без тегов | + +- [x] Реализовать чтение `GIT_REF` / `GIT_VERIFY_MODE` в `update_ssh_monitor.sh` +- [x] Дефолты для 2.2.0: `GIT_REF` пуст → `GIT_BRANCH`; `GIT_VERIFY_MODE=off` в скрипте, **`tag`** в prod-документации +- [x] **`docs/auto-update.ru.md`**: отдельный раздел с примерами systemd и таблицей режимов + +--- + +### 2.2 Без слепого `reset --hard` (C2) — **утверждено** + +- [x] При failed `git pull --ff-only`: **не** делать `reset --hard` автоматически +- [x] Лог + stderr: «история разошлась, требуется ручное вмешательство или новый clone» +- [x] Опционально: `GIT_ALLOW_RESET=1` только для ручного/CI (документировать риск) + +--- + +### 2.3 Release manifest (C2, C4) — **утверждено** + +- [x] Файл в репо, например `release/manifest-2.2.0-SAC.json`: + +```json +{ + "version": "2.2.0-SAC", + "git_commit": "abc123...", + "files": { + "ssh-monitor": "sha256:...", + "sac-client.sh": "sha256:...", + "update_ssh_monitor.sh": "sha256:...", + "ssh-monitor-watchdog": "sha256:..." + } +} +``` + +- [x] Публиковать manifest при каждом релизе (в git, рядом с тегом) +- [x] Updater: после checkout сверять SHA256 файлов из клона с manifest **до** копирования в `/usr/local/bin` +- [x] Несовпадение → exit 1, ничего не перезаписывать +- [x] Скрипт/цель в `Makefile` + `contrib/manifest/generate.py`; pre-commit hook; ручной `.local/` (не в remote) + +**Связь с 2.4:** manifest из **того же** `REPO_URL` защищает от подмены файлов в clone и от «не того» коммита; не защищает от полной компрометации git-сервера (для этого GPG). + +--- + +### 2.4 GPG-подписи тегов — **опционально, скорее отложить** + +**Вопрос:** если обновляем только из своего `REPO_URL` (kalinamall), нужен ли GPG? + +| Угроза | Manifest + pinned tag | + GPG tag | +|--------|----------------------|-----------| +| Подмена файлов в clone / wrong commit | Да | Да | +| Компрометация аккаунта git (вредоносный force-push) | **Нет** | Да (если ключ не украден) | +| Доверенный LAN git у оператора | Обычно достаточно manifest | Nice-to-have | + +**Решение для roadmap:** + +- [x] **2.2.0:** не блокировать релиз на GPG; `GIT_VERIFY_MODE=tag` без `-v` +- [ ] **Backlog:** `GIT_GPG_VERIFY=1` + ключ в `/etc/ssh-monitor/trusted-release-key.asc`, если позже понадобится +- [x] Документировать: при доверенном закрытом зеркале manifest + `GIT_REF=tag` — **достаточный минимум** + +--- + +### 2.5 Re-exec updater только после verify (C4) — **утверждено** + +- [x] `UPDATER_REEXEC` / копирование `update_ssh_monitor.sh` в `/opt/scripts/` — только если manifest SHA256 совпал +- [x] Иначе: лог, старая версия updater остаётся, exit 1 + +--- + +## Фаза 3 — 2.3.0-SAC (парсер конфига) + +### 3.1 Парсер без `source` (C1) — **утверждено** + +- [x] Whitelist ключей: `TELEGRAM_*`, `SAC_*`, `MAIL_*`, `NOTIFY_*`, числовые лимиты, пути и т.д. +- [x] Формат строк: `KEY="value"` / `KEY='value'` / `KEY=value` +- [x] Игнор `#` комментариев; неизвестные ключи — WARN + +**Файлы:** `ssh-monitor-perms.sh` (`ssh_monitor_config_load_file`), `ssh-monitor`, `ssh-monitor-watchdog` + +--- + +### 3.2 Миграция — **решение агента** + +- [x] **2.3.0:** парсер по умолчанию; `source` **удалён** +- [x] Синтаксис файла **не меняется** для пользователя — те же `KEY="value"` +- [x] В release notes: «поведение то же, выполнение bash из конфига невозможно» +- [x] `ssh-monitor --check-config` проверяет неизвестные/битые строки (WARN) и значения (ERROR) +- [x] Отдельная команда миграции **не нужна** (формат тот же) + +--- + +### 3.3 Валидация значений — **решение агента** + +- [x] URL: `SAC_URL` — http(s)://host +- [x] Enum: `UseSAC` ∈ `off|exclusive|dual|fallback`; `GIT_VERIFY_MODE` ∈ `off|tag|commit` +- [x] Числа: существующие `validate_numeric_or_default` +- [x] IP/CIDR в `WHITELIST_*` — базовая проверка формата +- [x] Ошибки валидации → exit 1 на старте и в `--check-config` + +--- + +### 3.x Удаление `BACKUP_WEBHOOK` (если утвердили soft-deprecate в 1.3) + +- [x] Удалить код и документацию в **2.3.0** + +--- + +## Фаза 4 — что делаем, что нет + +| Пункт | Версия | Решение | +|-------|--------|---------| +| **4.4** `ensure_ipset_installed` не на каждый update | 2.1.7 | **Делаем:** только `--deploy` / `first_deploy.sh`; обычный update не вызывает `apt-get` | ✅ | +| **4.3** Права whitelist-файла | 2.1.7 | **Делаем:** при загрузке `/etc/ssh_monitor_whitelist.txt` — WARN если не root:root | ✅ | +| **4.5** JSON healthcheck через `json.dumps` | 2.2.0 | **Делаем:** мелкий fix L3 | ✅ | +| **4.1** Секреты не через environ в Python | — | **Не делаем** (мало выигрыша при root) | +| **4.2** Telegram token в URL | — | **Не делаем** (ограничение Bot API) | +| **4.6** systemd hardening (non-root) | — | **Не делаем** (ipset/iptables требуют root) | +| **M6** Ротация логов | — | Уже есть `contrib/logrotate.d/` — только упоминание в README | +| **M7** `set_config_kv` sed | 2.2.0 | **Делаем:** экранирование `/` и `"` в `val` при merge конфига | ✅ | + +--- + +## Вне scope (согласовано не раздувать) + +- Жёсткий allowlist `REPO_URL` / зеркал — оператор задаёт свой URL +- Cert pinning TLS +- HashiCorp Vault / systemd-creds для секретов (backlog на годы) +- Регулярные внешние аудиты + +--- + +## Чек-лист «перед стартом работы завтра» + +- [x] **Релиз 2.1.7–2.1.9** — фаза 1 + SAC-first + watchdog server label +- [x] **Релиз 2.2.0** — scope: 2.1–2.3, 2.5, 4.5, 4.7(M7); **2.4 GPG отложить** +- [x] **Релиз 2.3.0** — scope: 3.1–3.3, удаление webhook +- [ ] **Дефолт `GIT_REF`** в 2.2.0: тег из `version.txt` vs `main` +- [x] **Миграция хостов:** SAC-first update, `known_hosts`, `REPO_URL` через SAC +- [ ] **Ingest mass-update backlog** — см. security-alert-center `docs/ingest-mass-update-backlog.ru.md` + +--- + +## Открытые вопросы на завтра + +1. **1.3** — `BACKUP_WEBHOOK_URL`: soft-deprecate (A), удалить сразу (B), оставить (C)? +2. **1.2** — достаточно WARN по умолчанию или сразу strict на новых установках? +3. **2.1** — дефолт `GIT_REF`: тег `version.txt` или ветка `main`? +4. Подтверждение: **ни на одном хосте** не используется Slack/Discord webhook через `BACKUP_WEBHOOK_URL`? + +--- + +*Документ создан для ревью. После утверждения — работа по фазам в отдельных коммитах с bump версии.* diff --git a/first_deploy.sh b/first_deploy.sh new file mode 100644 index 0000000..b2671c5 --- /dev/null +++ b/first_deploy.sh @@ -0,0 +1,5 @@ +#!/bin/bash +# Первичная установка ssh-monitor — обёртка над update_ssh_monitor.sh --deploy +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec "$SCRIPT_DIR/update_ssh_monitor.sh" --deploy "$@" diff --git a/readme_eng.md b/readme_eng.md new file mode 100644 index 0000000..c91371f --- /dev/null +++ b/readme_eng.md @@ -0,0 +1,220 @@ +# ssh-monitor + +Bash script that monitors **SSH**, **SUDO**, and **`systemd-logind`** events (local/graphical sessions, etc.), sends alerts via **Telegram** and **SMTP email**, optional backup webhook, automatic IP blocking (**ipset** + **iptables** / **ip6tables**: `hash:ip` sets with timeout, single `-m set` rule; details in **`IPSET-NOTES.txt`**), and a daily report. + +## Repository contents + +| File | Purpose | +|------|---------| +| **`ssh-monitor`** | Main loop: parses logs (**journalctl**: sshd, sudo, security events, **systemd-logind**), notification chain (Telegram, SMTP via **python3**, backup webhook); with **`ENABLE_IP_BAN=1`** tracks failed attempts, bans via **ipset**, reconciles with **iptables**/**ip6tables**, daily report, heartbeat, optional **Prometheus** textfile and JSON health. Modes: **`--check-config`**, **`--dry-run`**. | +| **`ssh-monitor-watchdog`** | Standalone script (usually via **systemd timer**): checks that the monitor service is active and the heartbeat file is fresh; on failure runs **`systemctl restart`** and sends Telegram or **`BACKUP_WEBHOOK_URL`**. Reads the same `TELEGRAM_*` / webhook from `/etc/ssh-monitor.conf`. | +| **`update_ssh_monitor.sh`** | Optional auto-update: `git pull` / `clone`, SHA256 compare with `/usr/local/bin/ssh-monitor`, restart `ssh-monitor.service`. See [docs/auto-update.ru.md](docs/auto-update.ru.md) (Russian). | + +**System packages and dependencies** (install on the server before running): step-by-step in [docs/install-prerequisites.ru.md](docs/install-prerequisites.ru.md) (Russian). + +## Configuration + +The script reads `/etc/ssh-monitor.conf` in `KEY="value"` format. + +1. Copy the example: + - `sudo cp ./ssh-monitor.conf.example /etc/ssh-monitor.conf` +2. Restrict permissions: + - `sudo chmod 600 /etc/ssh-monitor.conf` +3. Fill in at least **one notification channel** (otherwise the script exits immediately; see **«Mandatory requirement»** below and **«Notification channels and empty NOTIFY_CHAIN»**): + - for **Telegram**: `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`; + - or configure **SMTP** using the variables below with `NOTIFY_ORDER=""` (auto-selected channels). + +**Mandatory requirement:** after loading the config, **`NOTIFY_CHAIN`** must contain **at least one** channel (`telegram` or `email`). If there are **none** (empty `NOTIFY_ORDER` and neither Telegram nor SMTP qualify as “configured”, or `NOTIFY_ORDER` contains only unknown names), the script **does not** enter the main loop and exits with code **1**, stderr: **`Не настроен ни один канал отправки оповещений`**. The same applies to **`--check-config`** and **`--dry-run`**. The backup **`BACKUP_WEBHOOK_URL`** is **not** part of this check — it is used only if **every** channel in **`NOTIFY_CHAIN`** fails to deliver. + +Supported parameters: + +- `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID` +- `NOTIFY_ORDER` — channel list (`telegram`, `email`, or **tg**, **mail**); for each alert the script **tries all listed** channels in order (not “first success stops”). Empty = only configured channels are chained (default order in `ssh-monitor.conf.example`). If the resulting chain is empty — the script does not start (see **«Notification channels and empty NOTIFY_CHAIN»**). +- `MAIL_SMTP_HOST`, `MAIL_SMTP_PORT`, `MAIL_SMTP_USER`, `MAIL_SMTP_PASSWORD`, `MAIL_FROM`, `MAIL_TO`, `MAIL_SMTP_STARTTLS`, `MAIL_SMTP_SSL` — mail via **python3** (see channel table below) +- `BACKUP_WEBHOOK_URL` — fallback JSON `{"text":"..."}` (e.g. Slack Incoming Webhook) if **all** **`NOTIFY_CHAIN`** channels fail; **does not** replace the need for at least one primary channel (see step 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` — see **«systemd-logind monitoring»** below +- `DAILY_REPORT_HOUR` (0..23), `DAILY_REPORT_TZ` (optional), `NOTIFY_TZ` (optional), `SERVER_DISPLAY_NAME` (optional), `SSH_ACCEPT_NOTIFY_DEDUP_SEC`, `DAILY_REPORT_TOP_IPS` +- `BRUTE_WINDOW_SEC`, `BRUTE_MIN_FAILS`, `BRUTE_NOTIFY_COOLDOWN_SEC` +- `PROMETHEUS_TEXTFILE_DIR` — directory for `ssh_monitor.prom` (node_exporter textfile collector) +- `HEALTHCHECK_STATUS_FILE` — path to JSON with last loop iteration marker +- `ENABLE_IP_BAN` — `1` (default): monitoring and auto-ban via ipset; `0`: monitoring and alerts only (no ipset/ban) +- `BAN_TIME`, `MAX_ATTEMPTS`, `BAN_CHECK_INTERVAL`, `MONITOR_INTERVAL` +- `WHITELIST_IPS`, `WHITELIST_SUBNETS` (CSV, e.g. `ip1,ip2`) +- `WATCHDOG_MAX_HEARTBEAT_AGE`, `WATCHDOG_LOG_FILE`, `WATCHDOG_SERVICE_NAME`, `WATCHDOG_NOTIFY_ON_RECOVERY` + +If `/etc/ssh-monitor.conf` is missing or some values are empty, script defaults apply. + +### `/etc/ssh-monitor.conf` variables + +- `TELEGRAM_BOT_TOKEN` — Telegram bot token for alerts. +- `TELEGRAM_CHAT_ID` — chat/user ID for notifications. +- `NOTIFY_ORDER` — CSV of channel names (`telegram`, `email`, `tg`, `mail`). For **each** alert, delivery is attempted **to all** listed channels (if one fails, others still run; failures go to **`LOG_FILE`**). Empty string: build chain from configured channels only (default order telegram → email; “configured” criteria — **table** in **«Notification channels and empty NOTIFY_CHAIN»**). If no channels remain, the script exits: **«Не настроен ни один канал отправки оповещений»** (including `--check-config` and `--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 for `email` channel (**python3**; see “configured” table). +- `BACKUP_WEBHOOK_URL` — backup POST URL (JSON `{"text":"..."}`); fallback only, **not** counted as a configured channel (see step 3). +- `LOG_FILE` — main script log path. +- `LAST_HEARTBEAT_FILE` — file with last heartbeat timestamp. +- `LAST_REPORT_FILE` — date of last daily report `YYYY-MM-DD` (legacy Unix time on first run is interpreted automatically). +- `LAST_SSH_CHECK_FILE` — last SSH events check timestamp. +- `LAST_SUDO_CHECK_FILE` — last sudo events check timestamp. +- `LAST_SECURITY_EVENTS_FILE` — last “heavy” security journal check. +- `LAST_LOGIND_CHECK_FILE` — last `systemd-logind` journal processing (Unix time); ~30 minutes of history on first run. +- `BAN_LIST_FILE` — ban state file (IP, ban end time, metadata). +- `ENABLE_LOGIND_MONITOR` — `1` enables `journalctl -u systemd-logind`, `0` disables this block. +- `LOGIND_NOTIFY_NEW` — `1` send Telegram on new logind session (*New session … of user …*). +- `LOGIND_NOTIFY_REMOVED` — `1` send Telegram on session end (*Removed session …*); default `0` (log only to avoid spam). +- `LOGIND_NOTIFY_FAILED` — `1` send Telegram on logind lines containing *failed* (broad filter; disable if needed). +- `LOGIND_SKIP_REMOTE` — `1` (default): for a new logind session, if `loginctl` is available, skip duplicate Telegram when already covered by **`monitor_ssh`**: **`Type=ssh`** or **`Service`** points to **sshd** (often **`Type=tty` + `Service=sshd`**); short retries if `loginctl` returns empty. Set `0` for separate logind alerts for SSH sessions too. +- `DAILY_REPORT_HOUR` — hour (0..23) after which at most one daily report is sent in the **current calendar day** (in the zone below). +- `DAILY_REPORT_TZ` — optional **IANA** zone (`Europe/Moscow`, …). If **empty**, report uses the monitor process zone (usually matches `timedatectl` / `/etc/localtime`). If the unit sets `TZ=UTC`, without `DAILY_REPORT_TZ` the report uses **UTC** — set an explicit zone in config. +- `NOTIFY_TZ` — **IANA** zone for **«🕐 Время»** lines in Telegram/email. **Empty** — use **`DAILY_REPORT_TZ`** if set, else process zone. Common mismatch: **`ssh-monitor.service`** has **`Environment=TZ=UTC`** — set **`NOTIFY_TZ`** or **`DAILY_REPORT_TZ`** to your locale, e.g. `Asia/Vladivostok`. +- `SERVER_DISPLAY_NAME` — label in **«🖥️ Server»** line on every alert. **Empty** — `hostname` plus primary IPv4 when available. Set a fixed name when many hosts share one Telegram chat. Details: [docs/notifications.ru.md](docs/notifications.ru.md) (Russian). +- `SERVER_IPV4` — optional override for SAC `host.ipv4`. If empty, the script auto-detects host IPv4. +- `SSH_ACCEPT_NOTIFY_DEDUP_SEC` — at most one Telegram per successful SSH (**Accepted**) per **user + IP** pair within **N** seconds (default **5**). Removes duplicates when journal has two lines with **different client ports** for one login. **`0`** disables this cross-iteration dedup (in-pass dedup by `user|ip|port` remains). +- `DAILY_REPORT_TOP_IPS` — how many IPs in the failed-attempts top for 24 hours. +- `BRUTE_WINDOW_SEC` — window (seconds) for “mass” brute-force evaluation via `journalctl`. +- `BRUTE_MIN_FAILS` — minimum failures in the window to alert. +- `BRUTE_NOTIFY_COOLDOWN_SEC` — cooldown between repeat alerts for the same IP. +- `PROMETHEUS_TEXTFILE_DIR` — if set to an existing directory, writes `ssh_monitor.prom` with `ssh_monitor_last_loop_unixtime` each iteration. +- `HEALTHCHECK_STATUS_FILE` — if set, updates JSON `{ "ts", "hostname", "dry_run" }` each iteration. +- `ENABLE_IP_BAN` — enable auto-ban (`1`) or monitor-only with alerts (`0`; **ipset** not required). +- `BAN_TIME` — ban duration in seconds (`ENABLE_IP_BAN=1`). +- `MAX_ATTEMPTS` — failed SSH attempts before auto-ban. +- `BAN_CHECK_INTERVAL` — interval to check expired bans and reconcile ban list with firewall (seconds). +- `MONITOR_INTERVAL` — pause between main loop iterations (seconds). +- `WHITELIST_IPS` — comma-separated whitelist (IPv4/IPv6 exact match). +- `WHITELIST_SUBNETS` — **IPv4-only** CIDR whitelist, comma-separated (e.g. `10.0.0.0/24`). +- `WATCHDOG_MAX_HEARTBEAT_AGE` — seconds after which heartbeat is stale. +- `WATCHDOG_LOG_FILE` — watchdog log file. +- `WATCHDOG_SERVICE_NAME` — systemd service watched by watchdog (default `ssh-monitor.service`). +- `WATCHDOG_NOTIFY_ON_RECOVERY` — `1` enables watchdog “OK” messages when healthy, `0` disables. + +### Notification channels and empty NOTIFY_CHAIN + +What counts as a **configured** channel (only these join the auto chain when **`NOTIFY_ORDER=""`**): + +| Channel | “Configured” condition | +|---------|------------------------| +| `telegram` | **`TELEGRAM_BOT_TOKEN`** and **`TELEGRAM_CHAT_ID`** set | +| `email` | non-empty **`MAIL_SMTP_HOST`**, **`MAIL_FROM`**, **`MAIL_TO`**, **`python3`** in PATH | + +If **`NOTIFY_CHAIN` is empty**, the script exits with **`Не настроен ни один канал отправки оповещений`** (see step 3 in **Configuration**). + +### systemd-logind monitoring + +- Collects **`systemd-logind`** unit messages via **`journalctl`** (`-o cat`): new/ended sessions, and lines containing *failed* (if enabled). +- Without **`journalctl`**, this block **does not run** (like other journal-based features). +- “New session” parsing expects typical English formats (`New session … of user …`). On mismatch, the line is still logged with a parse warning. +- **`loginctl`** is used for **`LOGIND_SKIP_REMOTE`**: **Type** and **Service** decide whether to avoid duplicate Telegram with **`monitor_ssh`** (see `LOGIND_SKIP_REMOTE` above). +- **`monitor_ssh`**: **sshd** events mostly **`journalctl _COMM=sshd`** (fallback units `sshd` / `ssh`), lines **`sort -u`**, **`Accepted`** dedup per **user|IP|port** within one pass; between passes **`SSH_ACCEPT_NOTIFY_DEDUP_SEC`** for **user|IP** (see config). + +**`ssh-monitor --check-config`** prints current values, including logind settings. + +Hosts **without journald** and **`/var/log/auth.log`** caveats: section 4 in [docs/install-prerequisites.ru.md](docs/install-prerequisites.ru.md). + +### Server identification in alerts + +**All** notifications sent via **`notify_send()`** get a **«🖥️ Сервер: …»** line (Russian label in messages) right after the title: `hostname (IPv4)` or hostname only. Implemented centrally in **`message_ensure_server_line()`**; no need to add the line in each alert builder. + +- Requires **`hostname`** and **`ip`** (see prerequisites doc). +- Optional **`SERVER_DISPLAY_NAME`** in config for a fixed label. +- Message types and examples: [docs/notifications.ru.md](docs/notifications.ru.md) (Russian). + +### Auto-update + +**`update_ssh_monitor.sh`** can run on a schedule to `git pull`, compare SHA256 with `/usr/local/bin/ssh-monitor`, copy on change, and restart **`ssh-monitor.service`**. Does not modify `/etc/ssh-monitor.conf`. + +Setup guide: [docs/auto-update.ru.md](docs/auto-update.ru.md) (Russian). + +## Run modes + +Normal run (root): + +```bash +sudo bash ./ssh-monitor +``` + +Check config and syntax **without** starting the loop, **without** changing `iptables`, no mandatory Telegram send: + +```bash +sudo bash ./ssh-monitor --check-config +``` + +**Dry-run** (firewall rules unchanged; notifications go to stderr instead of sending): + +```bash +sudo bash ./ssh-monitor --dry-run +``` + +## Syntax check + +```bash +bash -n ./ssh-monitor +``` + +## Release tarball + +Version is set as **`SSH_MONITOR_VERSION`** in **`ssh-monitor`** and **`version.txt`** (same string as in the README header). **`sac-client.sh`** reports the same version to SAC. Build from the git tree: + +```bash +make dist +``` + +Produces `ssh-monitor-.tar.gz` (via `git archive`). Prebuilt archives are attached to [GitHub releases](https://git.papatramp.ru/PapaTramp/ssh-monitor/releases). + +## Installing the script, config, and systemd + +1. Install the script: + - `sudo install -m 750 ./ssh-monitor /usr/local/bin/ssh-monitor` +2. Ensure config exists (see **Configuration**): + - `sudo cp ./ssh-monitor.conf.example /etc/ssh-monitor.conf` + - `sudo chmod 600 /etc/ssh-monitor.conf` +3. Create `/etc/systemd/system/ssh-monitor.service`: + +```ini +[Unit] +Description=SSH Monitor with Telegram alerts +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/ssh-monitor +Restart=always +RestartSec=5 +User=root + +[Install] +WantedBy=multi-user.target +``` + +4. Enable and start: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable ssh-monitor +sudo systemctl start ssh-monitor +``` + +5. Status and logs: + +```bash +sudo systemctl status ssh-monitor +sudo journalctl -u ssh-monitor -f +``` + +## Log rotation + +Example `logrotate` config: `contrib/logrotate.d/ssh-monitor`. Copy to `/etc/logrotate.d/` and adjust paths for your `LOG_FILE` / `WATCHDOG_LOG_FILE` and state files under `/var/log/` (including `LAST_*` if you rotate them separately). + +## Watchdog (automatic recovery) + +The watchdog checks `ssh-monitor.service` activity and heartbeat freshness (`LAST_HEARTBEAT_FILE`). On failure it runs `systemctl restart` and sends a notification (Telegram, or `BACKUP_WEBHOOK_URL` if Telegram fails). + +Step-by-step **ssh-monitor-watchdog** and **timer** setup: section 6 in [docs/install-prerequisites.ru.md](docs/install-prerequisites.ru.md). + +**Keywords (search):** SSH, **sshd**, **bash** on **Linux**, **Telegram**, **SMTP**, **systemd-logind**, **sudo**, **journalctl**, **iptables** / **ip6tables**, automatic **IP ban** and **whitelist**, **systemd** unit, **daily report**, **heartbeat**, **OpenSSH**, server security, **Prometheus** textfile, optional **watchdog**. + +## License + +This project is licensed under the **MIT License** — see [LICENSE](LICENSE). diff --git a/release/manifest-2.2.0-SAC.json b/release/manifest-2.2.0-SAC.json new file mode 100644 index 0000000..8ac9e06 --- /dev/null +++ b/release/manifest-2.2.0-SAC.json @@ -0,0 +1,11 @@ +{ + "version": "2.2.0-SAC", + "git_commit": "275d305d627b462cae141605a002002525aa474c", + "files": { + "ssh-monitor": "sha256:517e4a210f271f57717b77746352ce2c60579cf89958c1c36501951901b885d0", + "sac-client.sh": "sha256:ffcbb0ed28e4247ccb5e74dd6e77d020f93eab8a17f9716e8913a17bc899274b", + "update_ssh_monitor.sh": "sha256:8108dd703095d54d51070933f074c90805f383967a3d2a6d63825898ad483231", + "ssh-monitor-watchdog": "sha256:5299bdcb481e7ca1d1042579d5c1ad8b13f3f72c1a6e922bcffd070b6c30ec80", + "ssh-monitor-perms.sh": "sha256:c67b56060141f67c651906b31afc220a1040c151fb00f60cc5f2511d6664dc11" + } +} diff --git a/release/manifest-2.2.1-SAC.json b/release/manifest-2.2.1-SAC.json new file mode 100644 index 0000000..06ffc71 --- /dev/null +++ b/release/manifest-2.2.1-SAC.json @@ -0,0 +1,11 @@ +{ + "version": "2.2.1-SAC", + "git_commit": "", + "files": { + "ssh-monitor": "sha256:9fa2eaadceffe70358d83029668cc9bdc423697626f1c84a62552901490adcd9", + "sac-client.sh": "sha256:ffcbb0ed28e4247ccb5e74dd6e77d020f93eab8a17f9716e8913a17bc899274b", + "update_ssh_monitor.sh": "sha256:5e27e3514532500b84d8eca67bb968ee1dde365564ce4c3710422c43e090dc72", + "ssh-monitor-watchdog": "sha256:d1e786247433b6340a8fd2f1578abb2933149fc328f7d0dd7dcfd2319b94dedd", + "ssh-monitor-perms.sh": "sha256:86c919032294d47a4daf6681fd6e527babfacf778340dd4c0bcb33f63e5d063e" + } +} diff --git a/release/manifest-2.2.2-SAC.json b/release/manifest-2.2.2-SAC.json new file mode 100644 index 0000000..094675c --- /dev/null +++ b/release/manifest-2.2.2-SAC.json @@ -0,0 +1,11 @@ +{ + "version": "2.2.2-SAC", + "git_commit": "", + "files": { + "ssh-monitor": "sha256:be35da47fb1cf370628795bfdfe34195a7ba098c666bd8aa092929a168a10ef6", + "sac-client.sh": "sha256:4c481effd0e50b3db8cfa3baf3c5f228892530939df6f534a5d3d3d8a79db048", + "update_ssh_monitor.sh": "sha256:510d63b43ea02e5aaa4385bcc6c9396ad99637c377c9880125cb2168e9d422cd", + "ssh-monitor-watchdog": "sha256:41aa139cab0fb275249780953fceb3f8ebe01c49bf96f901dd3cf4d2fb3a9274", + "ssh-monitor-perms.sh": "sha256:53ebf15138d59be1610e5bc14a107b942d9a7fd039683abfd2fdd0aa0b54b933" + } +} diff --git a/release/manifest-2.2.3-SAC.json b/release/manifest-2.2.3-SAC.json new file mode 100644 index 0000000..e5d3753 --- /dev/null +++ b/release/manifest-2.2.3-SAC.json @@ -0,0 +1,11 @@ +{ + "version": "2.2.3-SAC", + "git_commit": "", + "files": { + "ssh-monitor": "sha256:875252eb23640cbae34bcaaf71d07b091c71e04c29d5d5d5e90851083c5bfc21", + "sac-client.sh": "sha256:4c481effd0e50b3db8cfa3baf3c5f228892530939df6f534a5d3d3d8a79db048", + "update_ssh_monitor.sh": "sha256:76c610ed229d510c41ddd0d10db2b68d48c08b89eb1c8252341ce85f088bf6cd", + "ssh-monitor-watchdog": "sha256:41aa139cab0fb275249780953fceb3f8ebe01c49bf96f901dd3cf4d2fb3a9274", + "ssh-monitor-perms.sh": "sha256:a1ca9e72a9bd7162324c308849cfeebd449228c308edb03191171e9d3187658c" + } +} diff --git a/release/manifest-2.3.0-SAC.json b/release/manifest-2.3.0-SAC.json new file mode 100644 index 0000000..f4dfd98 --- /dev/null +++ b/release/manifest-2.3.0-SAC.json @@ -0,0 +1,11 @@ +{ + "version": "2.3.0-SAC", + "git_commit": "", + "files": { + "ssh-monitor": "sha256:3fd3348b46f444d04155e4b3e52eff6ed22d45587cedaf7584821a66013f8ec1", + "sac-client.sh": "sha256:4c481effd0e50b3db8cfa3baf3c5f228892530939df6f534a5d3d3d8a79db048", + "update_ssh_monitor.sh": "sha256:76c610ed229d510c41ddd0d10db2b68d48c08b89eb1c8252341ce85f088bf6cd", + "ssh-monitor-watchdog": "sha256:c03c50162b552a7790dd0a63eecec08d672ee04955281b5ec434ed92af5e0e2f", + "ssh-monitor-perms.sh": "sha256:d78349904ea5b0729716f47a65a9ceb531940b6ac92460a7f8e1099738290fd3" + } +} diff --git a/release/manifest-2.3.1-SAC.json b/release/manifest-2.3.1-SAC.json new file mode 100644 index 0000000..c67af9d --- /dev/null +++ b/release/manifest-2.3.1-SAC.json @@ -0,0 +1,11 @@ +{ + "version": "2.3.1-SAC", + "git_commit": "", + "files": { + "ssh-monitor": "sha256:0219054a824242ef800410fd17843e0c45e0572608dfa4825168fbcee0d97012", + "sac-client.sh": "sha256:4c481effd0e50b3db8cfa3baf3c5f228892530939df6f534a5d3d3d8a79db048", + "update_ssh_monitor.sh": "sha256:76c610ed229d510c41ddd0d10db2b68d48c08b89eb1c8252341ce85f088bf6cd", + "ssh-monitor-watchdog": "sha256:c03c50162b552a7790dd0a63eecec08d672ee04955281b5ec434ed92af5e0e2f", + "ssh-monitor-perms.sh": "sha256:a4e9be8b9d095abf036c1265186863c2e490baf6f05798010dd17c41197a29e4" + } +} diff --git a/release/manifest-2.3.2-SAC.json b/release/manifest-2.3.2-SAC.json new file mode 100644 index 0000000..ccaab35 --- /dev/null +++ b/release/manifest-2.3.2-SAC.json @@ -0,0 +1,11 @@ +{ + "version": "2.3.2-SAC", + "git_commit": "", + "files": { + "ssh-monitor": "sha256:ca64d1b2cfda29451704e43d6d5995dbdd5cac44f7e4d85f52d8f54429083c0f", + "sac-client.sh": "sha256:e3b561280d1c146f565a1fa73e4df29f07b4ffdf149c2b30074fac98722ee9b3", + "update_ssh_monitor.sh": "sha256:76c610ed229d510c41ddd0d10db2b68d48c08b89eb1c8252341ce85f088bf6cd", + "ssh-monitor-watchdog": "sha256:c03c50162b552a7790dd0a63eecec08d672ee04955281b5ec434ed92af5e0e2f", + "ssh-monitor-perms.sh": "sha256:19531b61ebac2e2f9a19fcd9fa40b6fca4651a979dfc6ab9a347a0398ce5e012" + } +} diff --git a/sac-client.sh b/sac-client.sh new file mode 100644 index 0000000..3e49cb0 --- /dev/null +++ b/sac-client.sh @@ -0,0 +1,532 @@ +# SAC client for ssh-monitor (source from ssh-monitor) +# Версия для SAC = SSH_MONITOR_VERSION в ssh-monitor (отдельный номер здесь не задаётся). +# shellcheck shell=bash + +sac_tls_insecure_enabled() { + case "${SAC_TLS_INSECURE:-0}" in + 1 | yes | true | on) return 0 ;; + esac + return 1 +} + +sac_warn_tls_insecure() { + if sac_tls_insecure_enabled; then + write_log 'CRITICAL: SAC_TLS_INSECURE=1 — проверка TLS для SAC отключена (риск MITM). Только для краткой отладки в lab.' + fi +} + +sac_curl() { + if sac_tls_insecure_enabled; then + curl -k -sS "$@" + else + curl -sS "$@" + fi +} +sac_normalize_mode() { + printf '%s' "${1:-off}" | tr '[:upper:]' '[:lower:]' +} + +sac_is_configured() { + [ -n "${SAC_URL:-}" ] && [ -n "${SAC_API_KEY:-}" ] +} + +sac_base_url() { + local url="${SAC_URL%/}" + case "$url" in + */api/v1/events) url="${url%/api/v1/events}" ;; + esac + printf '%s' "${url%/}" +} + +# URL ingest (POST): всегда .../api/v1/events, даже если в конфиге указан только хост +sac_ingest_url() { + local base + base="$(sac_base_url)" + [ -n "$base" ] || return 1 + printf '%s/api/v1/events' "$base" +} + +sac_agent_instance_id() { + local id_file="${SAC_AGENT_ID_FILE:-/var/lib/ssh-monitor/agent_instance_id}" + local dir + dir="$(dirname "$id_file")" + if [ ! -d "$dir" ]; then + ssh_monitor_secure_dir "$dir" 2>/dev/null || mkdir -p "$dir" 2>/dev/null || id_file="/tmp/ssh-monitor-agent_instance_id" + fi + if [ -f "$id_file" ]; then + cat "$id_file" + return 0 + fi + local new_id + new_id="$(python3 -c 'import uuid; print(uuid.uuid4())')" + ssh_monitor_secure_file "$id_file" 2>/dev/null || true + printf '%s\n' "$new_id" >"$id_file" 2>/dev/null || true + printf '%s' "$new_id" +} + +sac_check_health() { + local base code + base="$(sac_base_url)" + [ -n "$base" ] || return 1 + if [ "${DRY_RUN:-0}" = "1" ]; then + return 0 + fi + code="$(sac_curl -o /dev/null -w '%{http_code}' \ + --connect-timeout "${SAC_TIMEOUT_SEC:-45}" \ + --max-time "${SAC_TIMEOUT_SEC:-45}" \ + "${base}/health" || echo 000)" + [ "$code" = "200" ] +} + +sac_spool_write() { + local event_id="$1" + local body="$2" + local dir="${SAC_SPOOL_DIR:-/var/lib/ssh-monitor/sac-spool}" + ssh_monitor_secure_dir "$dir" 2>/dev/null || mkdir -p "$dir" 2>/dev/null || return 1 + ssh_monitor_secure_file "${dir}/${event_id}.json" 2>/dev/null || true + printf '%s' "$body" >"${dir}/${event_id}.json" +} + +sac_spool_remove() { + local event_id="$1" + local dir="${SAC_SPOOL_DIR:-/var/lib/ssh-monitor/sac-spool}" + rm -f "${dir}/${event_id}.json" +} + +sac_fail_count_file() { + printf '%s' "${SAC_FAIL_COUNT_FILE:-/var/lib/ssh-monitor/sac-fail.count}" +} + +sac_read_fail_count() { + local f + f="$(sac_fail_count_file)" + if [ -f "$f" ]; then + cat "$f" 2>/dev/null | tr -dc '0-9' + else + printf '0' + fi +} + +sac_write_fail_count() { + local n="$1" + local f + f="$(sac_fail_count_file)" + ssh_monitor_secure_file "$f" 2>/dev/null || mkdir -p "$(dirname "$f")" 2>/dev/null || true + printf '%s' "$n" >"$f" +} + +sac_reset_fail_count() { + sac_write_fail_count 0 +} + +# Внутренний флаг: при shutdown/SIGTERM не крутить счётчик (restart во время SAC-deploy). +sac_fail_count_increment_suppressed() { + case "${SAC_SUPPRESS_FAIL_COUNT:-0}" in + 1 | yes | true | on) return 0 ;; + esac + return 1 +} + +sac_increment_fail_count() { + local n max + if sac_fail_count_increment_suppressed; then + write_log "SAC: пропуск sac-fail.count (shutdown/maintenance)" + return 1 + fi + max="${SAC_FALLBACK_FAILURES:-5}" + n="$(sac_read_fail_count)" + n=$((n + 1)) + sac_write_fail_count "$n" + [ "$n" -ge "$max" ] +} + +# В fallback: после N подряд ошибок POST не дергать SAC, пока /health снова не OK. +sac_should_attempt_send() { + local mode + mode="$(sac_normalize_mode "${UseSAC:-off}")" + [ "$mode" = "fallback" ] || return 0 + local max n + max="${SAC_FALLBACK_FAILURES:-5}" + n="$(sac_read_fail_count)" + [ "$n" -lt "$max" ] && return 0 + if sac_check_health; then + sac_reset_fail_count + write_log "SAC: /health восстановлен, снова отправляем в SAC (fallback)" + return 0 + fi + write_log "WARN: SAC fallback: пропуск POST ($n>=$max сбоев подряд), только локальные каналы" + return 1 +} + +# POST готового JSON (ingest / повтор из spool). Возврат 0 при HTTP 201, 409 или 202 (legacy). +sac_post_payload() { + local payload="$1" + local event_id http_code tmp resp event_type + + if ! sac_is_configured; then + return 1 + fi + if [ "${DRY_RUN:-0}" = "1" ]; then + printf '[DRY-RUN SAC POST] %s\n' "$(printf '%s' "$payload" | head -c 120)" >&2 + return 0 + fi + + if ! sac_should_attempt_send; then + return 1 + fi + + event_id="$(printf '%s' "$payload" | python3 -c 'import json,sys; print(json.load(sys.stdin)["event_id"])')" || return 1 + event_type="$(printf '%s' "$payload" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("type","?"))')" || event_type="?" + + tmp="$(mktemp)" + resp="$(mktemp)" + printf '%s' "$payload" >"$tmp" + http_code="$(sac_curl -o "$resp" -w '%{http_code}' \ + -X POST "$(sac_ingest_url)" \ + -H "Authorization: Bearer ${SAC_API_KEY}" \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: ${event_id}" \ + --connect-timeout "${SAC_TIMEOUT_SEC:-45}" \ + --max-time "${SAC_TIMEOUT_SEC:-45}" \ + -d @"$tmp" 2>/dev/null || echo 000)" + rm -f "$tmp" + + if [ "$http_code" = "201" ] || [ "$http_code" = "409" ] || [ "$http_code" = "202" ]; then + sac_spool_remove "$event_id" 2>/dev/null || true + sac_reset_fail_count + write_log "SAC: принято event_id=$event_id type=$event_type" + return 0 + fi + + write_log "WARN: SAC POST HTTP $http_code: $(tr '\n' ' ' <"$resp" | head -c 200)" + sac_spool_write "$event_id" "$payload" + sac_increment_fail_count || write_log "WARN: SAC fallback: достигнут порог SAC_FALLBACK_FAILURES" + return 1 +} + +# Список spool: report.daily.* первыми (старые раньше), затем остальное. +sac_flush_spool_list_files() { + local dir="${1:-}" + [ -n "$dir" ] && [ -d "$dir" ] || return 0 + SPOOL_DIR="$dir" python3 <<'PY' +import glob +import json +import os + +dir_path = os.environ.get("SPOOL_DIR", "") +daily, other = [], [] +for path in glob.glob(os.path.join(dir_path, "*.json")): + event_type = "" + try: + with open(path, encoding="utf-8") as handle: + event_type = json.load(handle).get("type", "") or "" + except Exception: + pass + mtime = os.path.getmtime(path) + bucket = daily if event_type in ("report.daily.ssh", "report.daily.rdp") else other + bucket.append((mtime, path)) +for bucket in (daily, other): + for _, path in sorted(bucket): + print(path) +PY +} + +# Повторная отправка файлов из SAC_SPOOL_DIR (до max_files за вызов). +sac_flush_spool() { + local mode max_files="${1:-${SAC_SPOOL_FLUSH_MAX_FILES:-50}}" + mode="$(sac_normalize_mode "${UseSAC:-off}")" + case "$mode" in + off) return 0 ;; + esac + if ! sac_is_configured; then + return 0 + fi + + local dir="${SAC_SPOOL_DIR:-/var/lib/ssh-monitor/sac-spool}" + [ -d "$dir" ] || return 0 + + local f count=0 payload + while IFS= read -r f; do + [ -n "$f" ] && [ -f "$f" ] || continue + count=$((count + 1)) + [ "$count" -gt "$max_files" ] && break + payload="$(cat "$f")" + sac_post_payload "$payload" || true + done < <(sac_flush_spool_list_files "$dir") + return 0 +} + +# send_sac_event type severity title summary [details_json] +sac_send_event() { + local event_type="$1" + local severity="$2" + local title="$3" + local summary="$4" + local details_json="${5:-}" + + if ! sac_is_configured; then + write_log "WARN: SAC не настроен (SAC_URL / SAC_API_KEY)" + return 1 + fi + + if [ "${DRY_RUN:-0}" = "1" ]; then + printf '[DRY-RUN SAC] %s | %s | %s\n' "$event_type" "$severity" "$title" >&2 + return 0 + fi + + if ! sac_should_attempt_send; then + return 1 + fi + + local payload http_code event_id + payload="$(SAC_EVENT_TYPE="$event_type" \ + SAC_SEVERITY="$severity" \ + SAC_TITLE="$title" \ + SAC_SUMMARY="$summary" \ + SAC_DETAILS_JSON="$details_json" \ + SAC_AGENT_ID="$(sac_agent_instance_id)" \ + SAC_PRODUCT_VERSION="${SSH_MONITOR_VERSION:-unknown}" \ + SAC_SERVER_DISPLAY_NAME="$(server_display_name_with_ip)" \ + SAC_HOST_IPV4="${SERVER_IPV4:-}" \ + python3 <<'PY' +import json, os, socket, uuid +from datetime import datetime, timezone + +def details(): + raw = os.environ.get("SAC_DETAILS_JSON", "").strip() + if not raw: + return None + return json.loads(raw) + +def valid_ipv4(value: str) -> bool: + try: + parts = value.split(".") + return len(parts) == 4 and all(0 <= int(p) <= 255 for p in parts) + except Exception: + return False + +def detect_host_ipv4() -> str: + manual = os.environ.get("SAC_HOST_IPV4", "").strip() + if manual and valid_ipv4(manual): + return manual + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("1.1.1.1", 53)) + ip = s.getsockname()[0] + s.close() + if valid_ipv4(ip) and not ip.startswith(("127.", "169.254.")): + return ip + except Exception: + pass + try: + infos = socket.getaddrinfo(socket.gethostname(), None, family=socket.AF_INET) + for info in infos: + ip = info[4][0] + if valid_ipv4(ip) and not ip.startswith(("127.", "169.254.")): + return ip + except Exception: + pass + return "" + +host = socket.gethostname() +display = os.environ.get("SAC_SERVER_DISPLAY_NAME", "").strip() +host_ipv4 = detect_host_ipv4() +etype = os.environ["SAC_EVENT_TYPE"] +if etype.startswith(("ssh.", "auth.", "rdp.")): + category = "auth" +elif etype.startswith("privilege."): + category = "privilege" +elif etype.startswith("session."): + category = "session" +elif etype.startswith("report."): + category = "report" +elif etype.startswith("rdg."): + category = "network" +else: + category = "agent" +payload = { + "schema_version": "1.0", + "event_id": str(uuid.uuid4()), + "occurred_at": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"), + "source": { + "product": "ssh-monitor", + "product_version": os.environ.get("SAC_PRODUCT_VERSION", "unknown"), + "agent_instance_id": os.environ["SAC_AGENT_ID"], + }, + "host": { + "hostname": host, + "os_family": "linux", + **({"display_name": display} if display else {}), + **({"ipv4": host_ipv4} if host_ipv4 else {}), + }, + "category": category, + "type": os.environ["SAC_EVENT_TYPE"], + "severity": os.environ["SAC_SEVERITY"], + "title": os.environ["SAC_TITLE"], + "summary": os.environ["SAC_SUMMARY"], +} +d = details() +if d is not None: + payload["details"] = d +print(json.dumps(payload, ensure_ascii=False)) +PY +)" || return 1 + + sac_post_payload "$payload" +} + +# JSON details из переменных SAC_D_* (опционально) +sac_build_details_json() { + python3 <<'PY' +import json, os +out = {} +for k, v in os.environ.items(): + if k.startswith("SAC_D_") and v: + out[k[6:].lower()] = v +print(json.dumps(out) if out else "") +PY +} + +sac_is_heartbeat_only_event() { + [ "${1:-}" = "agent.heartbeat" ] +} + +sac_is_daily_report_event() { + case "${1:-}" in + report.daily.ssh|report.daily.rdp) return 0 ;; + *) return 1 ;; + esac +} + +# Добавляет generated_by / telegram_via в JSON details для ingest. +sac_merge_notify_details() { + local details_json="${1:-}" + local telegram_via="${2:-}" + DETAILS_IN="$details_json" TG_VIA="$telegram_via" python3 <<'PY' +import json, os +d = {} +raw = os.environ.get("DETAILS_IN", "").strip() +if raw: + try: + d = json.loads(raw) + except Exception: + d = {} +d.setdefault("generated_by", "agent") +via = os.environ.get("TG_VIA", "").strip() +if via: + d.setdefault("telegram_via", via) +print(json.dumps(d, ensure_ascii=False)) +PY +} + +# notify_or_sac type severity title summary telegram_message [details_json] +notify_or_sac() { + local event_type="$1" + local severity="$2" + local title="$3" + local summary="$4" + local telegram_message="${5:-$summary}" + local details_json="${6:-}" + if [ -z "$details_json" ] && [ -n "${SAC_D_USER:-}${SAC_D_IP:-}${SAC_D_COMMAND:-}" ]; then + details_json="$(sac_build_details_json)" + fi + unset SAC_D_USER SAC_D_IP SAC_D_SOURCE_IP SAC_D_PORT SAC_D_COMMAND SAC_D_ATTEMPT SAC_D_MAX SAC_D_RUN_AS SAC_D_PWD SAC_D_SESSION_ID SAC_D_TTY SAC_D_PTS SAC_D_SINCE 2>/dev/null || true + local mode + mode="$(sac_normalize_mode "${UseSAC:-off}")" + local tg_via="" + case "$mode" in + exclusive|fallback) tg_via="sac" ;; + dual) tg_via="agent" ;; + esac + if [ -n "$tg_via" ]; then + details_json="$(sac_merge_notify_details "$details_json" "$tg_via")" + fi + + # Периодический heartbeat — только SAC (браузер), без Telegram/email в любом режиме. + if sac_is_heartbeat_only_event "$event_type"; then + case "$mode" in + off) + return 0 + ;; + exclusive|dual|fallback) + sac_send_event "$event_type" "$severity" "$title" "$summary" "$details_json" + return $? + ;; + *) + return 0 + ;; + esac + fi + + # Суточный отчёт — только SAC/spool; при сбое ingest не дублировать в локальный Telegram. + if sac_is_daily_report_event "$event_type"; then + case "$mode" in + off) + return 0 + ;; + exclusive|dual|fallback) + if sac_send_event "$event_type" "$severity" "$title" "$summary" "$details_json"; then + return 0 + fi + write_log "WARN: daily report не принят SAC — остаётся в spool (локальный Telegram пропущен)" + return 1 + ;; + *) + return 0 + ;; + esac + fi + + case "$mode" in + off) + notify_send "$telegram_message" + ;; + exclusive) + sac_send_event "$event_type" "$severity" "$title" "$summary" "$details_json" + ;; + dual) + sac_send_event "$event_type" "$severity" "$title" "$summary" "$details_json" || true + NOTIFY_SKIP_SAC_MIRROR=1 notify_send "$telegram_message" + ;; + fallback) + if sac_send_event "$event_type" "$severity" "$title" "$summary" "$details_json"; then + return 0 + fi + NOTIFY_SKIP_SAC_MIRROR=1 notify_send "$telegram_message" + ;; + *) + write_log "WARN: неизвестный UseSAC=$mode, только Telegram" + notify_send "$telegram_message" + ;; + esac +} + +run_check_sac() { + printf 'Проверка SAC (ssh-monitor)\n' + printf 'UseSAC=%s\n' "${UseSAC:-off}" + case "$(sac_normalize_mode "${UseSAC:-off}")" in + exclusive) printf 'Режим exclusive: уведомления только в SAC (Telegram/email с агента не отправляются)\n' ;; + dual) printf 'Режим dual: SAC + локальные каналы NOTIFY_CHAIN\n' ;; + fallback) printf 'Режим fallback: SAC, при сбое — NOTIFY_CHAIN\n' ;; + esac + printf 'SAC_URL=%s\n' "${SAC_URL:-}" + if sac_is_configured; then + printf 'SAC ingest URL=%s\n' "$(sac_ingest_url)" + fi + if ! sac_is_configured; then + printf 'SAC: SAC_URL или SAC_API_KEY не заданы\n' >&2 + return 1 + fi + if sac_check_health; then + printf 'SAC health: OK\n' + else + printf 'SAC health: FAIL\n' >&2 + return 1 + fi + if sac_send_event "agent.test" "info" "SAC test" "ssh-monitor --check-sac"; then + printf 'SAC ingest agent.test: OK (ожидается HTTP 201)\n' + return 0 + fi + printf 'SAC ingest agent.test: FAIL\n' >&2 + return 1 +} diff --git a/scripts/pilot-verify-exclusive.sh b/scripts/pilot-verify-exclusive.sh new file mode 100644 index 0000000..7c7f3fe --- /dev/null +++ b/scripts/pilot-verify-exclusive.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# Проверка готовности хоста к пилоту UseSAC=exclusive (фаза 2.1). +set -euo pipefail + +CONFIG="${CONFIG_FILE:-/etc/ssh-monitor.conf}" +MONITOR="${SSH_MONITOR_BIN:-/usr/local/bin/ssh-monitor}" + +if [ -f "$CONFIG" ]; then + # shellcheck disable=SC1090 + . "$CONFIG" +fi + +fail=0 +ok() { printf '[OK] %s\n' "$1"; } +bad() { printf '[FAIL] %s\n' "$1"; fail=1; } + +mode="$(printf '%s' "${UseSAC:-off}" | tr '[:upper:]' '[:lower:]')" +[ "$mode" = "exclusive" ] || bad "UseSAC должен быть exclusive (сейчас: ${UseSAC:-off})" +[ -n "${SAC_URL:-}" ] && [ -n "${SAC_API_KEY:-}" ] || bad "Задайте SAC_URL и SAC_API_KEY" +[ -x "$MONITOR" ] || bad "Не найден $MONITOR" +[ -f "${MONITOR%/*}/sac-client.sh" ] || bad "Нет sac-client.sh рядом с ssh-monitor" + +if "$MONITOR" --check-sac; then + ok "--check-sac" +else + bad "--check-sac" +fi + +spool="${SAC_SPOOL_DIR:-/var/lib/ssh-monitor/sac-spool}" +pending=0 +if [ -d "$spool" ]; then + pending=$(find "$spool" -maxdepth 1 -name '*.json' 2>/dev/null | wc -l) +fi +printf ' Spool %s: %s файл(ов)\n' "$spool" "$pending" + +if [ "$fail" -eq 0 ]; then + printf '\nПилот exclusive: хост готов. Сгенерируйте SSH/sudo событие и проверьте только в SAC UI.\n' + exit 0 +fi +printf '\nИсправьте ошибки выше.\n' +exit 1 diff --git a/scripts/push-github-snapshot.sh b/scripts/push-github-snapshot.sh new file mode 100644 index 0000000..f89e2ce --- /dev/null +++ b/scripts/push-github-snapshot.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# Публичный snapshot на github: без prod URL, токенов и kalinamall-only путей. +set -Eeuo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK="$(mktemp -d "${TMPDIR:-/tmp}/ssh-monitor-github-snapshot.XXXX")" +trap 'rm -rf "$WORK"' EXIT + +REF="${1:-HEAD}" +VERSION="$(git -C "$ROOT" show "$REF":version.txt 2>/dev/null | tr -d '[:space:]' || true)" +MSG="${2:-chore: public release ${VERSION:-snapshot} (SSH Monitor, no Cursor IDE traces)}" + +echo "[snapshot] export $REF -> $WORK" +git -C "$ROOT" archive --format=tar "$REF" \ + | tar -x -C "$WORK" \ + --exclude='.cursor' \ + --exclude='.env' + +apply_sed() { + local expr=$1 + if [[ "$(uname -s)" == Darwin ]]; then + find "$WORK" -type f \( -name '*.md' -o -name '*.sh' -o -name '*.example' \) \ + ! -path '*/scripts/push-github-snapshot.sh' \ + -print0 | xargs -0 sed -i '' -E "$expr" 2>/dev/null || true + else + find "$WORK" -type f \( -name '*.md' -o -name '*.sh' -o -name '*.example' \) \ + ! -path '*/scripts/push-github-snapshot.sh' \ + -print0 | xargs -0 sed -i -E "$expr" 2>/dev/null || true + fi +} + +echo "[snapshot] rewrite hosts and internal URLs" +apply_sed 's#https://git\.kalinamall\.ru/PapaTramp#https://github.com/PTah#g' +apply_sed 's#git\.kalinamall\.ru/PapaTramp#github.com/PTah#g' +apply_sed 's#git\.kalinamall\.ru/papatramp#github.com/PTah#g' +apply_sed 's#git\.kalinamall\.ru#github.com#g' +apply_sed 's#sac\.kalinamall\.ru#sac.example.com#g' +apply_sed 's#remote add kalinamall#remote add origin#g' +apply_sed 's#remote set-url kalinamall#remote set-url origin#g' +apply_sed 's#local name=kalinamall#local name=origin#g' +apply_sed 's#всегда kalinamall#всегда origin#g' + +cat >"$WORK/.gitignore" <<'EOF' +.DS_Store +CalculatorApp/ +.env +.env.* +ssh-monitor.conf +ssh-monitor-*.tar.gz +rutube-download.py +.ytdl-archive.txt +EOF + +cat >"$WORK/ssh-monitor.conf.example" <<'EOF' +# Example configuration — copy to /etc/ssh-monitor.conf and chmod 600. +# Never commit real tokens or passwords to git. + +TELEGRAM_BOT_TOKEN="YOUR_TELEGRAM_BOT_TOKEN" +TELEGRAM_CHAT_ID="YOUR_TELEGRAM_CHAT_ID" + +# At least one notification channel (telegram or email) is required. +NOTIFY_ORDER="" + +# Security Alert Center (optional) +USE_SAC="off" +SAC_URL="https://sac.example.com/api/v1/events" +# SAC_API_KEY="your-ingest-api-key" + +# SMTP (optional, requires python3) +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" + +# Auto-update script default (override on your server) +# REPO_URL=https://git.papatramp.ru/PapaTramp/ssh-monitor.git +EOF + +if [[ -f "$WORK/README.md" ]]; then + python3 - "$WORK/README.md" <<'PY' +import pathlib +import sys + +path = pathlib.Path(sys.argv[1]) +text = path.read_text(encoding="utf-8") +text = "".join( + line for line in text.splitlines(keepends=True) + if not line.startswith("> **Закрытый инстанс Gitea:**") +) +header = "# ssh-monitor\n\n**Версия:**" +if header in text and "Bash monitor for" not in text.split("**Версия:**", 1)[0]: + text = text.replace( + header, + """# ssh-monitor + +Bash monitor for **SSH**, **sudo**, and **systemd-logind** on Linux: Telegram/SMTP alerts, optional IP ban (ipset/iptables), daily report, SAC integration. + +**Repository:** https://git.papatramp.ru/PapaTramp/ssh-monitor + +# ssh-monitor + +**Версия:**""", + 1, + ) +old = "## Лицензия\n\nПроект распространяется по лицензии **MIT** — см. файл [LICENSE](LICENSE).\n" +new = """## Topics (GitHub) + +`ssh`, `linux`, `telegram-bot`, `smtp`, `iptables`, `ipset`, `systemd`, `security`, `monitoring`, `openssh` + +## License + +MIT — see [LICENSE](LICENSE). Copyright (c) 2026 Andrey "PapaTramp" Lutsenko. + +
+English summary + +Single-file Bash agent parsing journalctl, notification chain, brute-force IP blocking, heartbeat, optional Prometheus textfile and Security Alert Center ingest via sac-client.sh. + +
+""" +if old not in text: + raise SystemExit("README footer block not found; update push-github-snapshot.sh") +path.write_text(text.replace(old, new), encoding="utf-8") +PY +fi + +if [[ -f "$WORK/LICENSE" ]]; then + if [[ "$(uname -s)" == Darwin ]]; then + sed -i '' 's/Copyright \(c\) 2026 PTah/Copyright (c) 2026 Andrey "PapaTramp" Lutsenko/' "$WORK/LICENSE" + else + sed -i 's/Copyright (c) 2026 PTah/Copyright (c) 2026 Andrey "PapaTramp" Lutsenko/' "$WORK/LICENSE" + fi +fi + +echo "[snapshot] secret scan" +if grep -R -E 'AAE[a-zA-Z0-9_-]{30,}|sac_[A-Za-z0-9]{20,}|[0-9]{8,}:[A-Za-z0-9_-]{30,}' "$WORK" 2>/dev/null; then + echo " BLOCKED: possible secrets in snapshot tree" + exit 1 +fi + +echo "[snapshot] git commit in temp repo" +cd "$WORK" +git init -q +git checkout -q -b main +git add -A +git -c user.name="Andrey Lutsenko" -c user.email="papatramp@gmail.com" commit -q -m "$MSG" + +echo "[snapshot] push github main (force snapshot)" +git remote add github "$(git -C "$ROOT" remote get-url github)" +git push github main --force + +echo "[snapshot] OK -> github/main @ $(git rev-parse --short HEAD)" diff --git a/ssh-monitor b/ssh-monitor new file mode 100644 index 0000000..d9a8067 --- /dev/null +++ b/ssh-monitor @@ -0,0 +1,2211 @@ +#!/bin/bash +set -Eeuo pipefail +IFS=$'\n\t' + +# ============================================ +# КОНФИГУРАЦИЯ +# ============================================ + +CONFIG_FILE="/etc/ssh-monitor.conf" +SSH_MONITOR_VERSION="2.3.2-SAC" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [ -f "$SCRIPT_DIR/ssh-monitor-perms.sh" ]; then + # shellcheck source=ssh-monitor-perms.sh + source "$SCRIPT_DIR/ssh-monitor-perms.sh" +fi +if [ -f "$SCRIPT_DIR/sac-client.sh" ]; then + # shellcheck source=sac-client.sh + source "$SCRIPT_DIR/sac-client.sh" +else + sac_normalize_mode() { printf '%s' "${1:-off}" | tr '[:upper:]' '[:lower:]'; } + sac_is_configured() { return 1; } + sac_check_health() { return 1; } + notify_or_sac() { notify_send "${5:-$4}"; } + run_check_sac() { printf 'sac-client.sh не найден\n' >&2; return 1; } +fi + +TELEGRAM_BOT_TOKEN="" +TELEGRAM_CHAT_ID="" + +# Очередь оповещений: CSV из имён telegram, email (или tg, mail). +# Пусто = авто: только настроенные каналы, порядок telegram → email. +NOTIFY_ORDER="" + +# Security Alert Center: off | exclusive | dual | fallback +UseSAC="off" +SAC_URL="" +SAC_API_KEY="" +SAC_SPOOL_DIR="/var/lib/ssh-monitor/sac-spool" +SAC_TIMEOUT_SEC="45" +SAC_SPOOL_FLUSH_MAX_FILES="50" +SAC_SPOOL_MAX_AGE_HOURS="72" +SAC_SEND_HEARTBEAT="1" +SAC_FALLBACK_FAILURES="5" +SAC_TLS_INSECURE="0" +CONFIG_STRICT_PERMS="0" + +# Почта (SMTP через python3). Нужны MAIL_SMTP_HOST, MAIL_FROM, MAIL_TO. +MAIL_SMTP_HOST="" +MAIL_SMTP_PORT="587" +MAIL_SMTP_USER="" +MAIL_SMTP_PASSWORD="" +MAIL_FROM="" +MAIL_TO="" +MAIL_SMTP_STARTTLS="1" +MAIL_SMTP_SSL="0" + +# Конфигурация логов +LOG_FILE="/var/log/ssh_monitor.log" +LAST_HEARTBEAT_FILE="/var/log/last_heartbeat.txt" +# Интервал SAC agent.heartbeat (минуты). 240 = 4 ч. +HEARTBEAT_INTERVAL_MINUTES=240 +LAST_REPORT_FILE="/var/log/last_daily_report.txt" +DAILY_REPORT_ENABLED=1 +DAILY_REPORT_HOUR=9 +DAILY_REPORT_TOP_IPS=5 +# Пусто = календарь и час отчёта в зоне процесса (как `date`, обычно совпадает с /etc/localtime). +# Иначе IANA, например Europe/Moscow — явная зона для DAILY_REPORT_HOUR и даты «сегодня» в отчёте. +DAILY_REPORT_TZ="" +# IANA-зона для строк «🕐 Время» в Telegram/email. Пусто = тогда DAILY_REPORT_TZ, если она задана, иначе зона процесса (часто UTC у systemd — см. README). +NOTIFY_TZ="" +# Подпись сервера в уведомлениях: пусто = hostname (+ IPv4); иначе фиксированное имя (удобно при одинаковых hostname). +SERVER_DISPLAY_NAME="" +# Явный IPv4 хоста для SAC payload (host.ipv4). Пусто = автоопределение. +SERVER_IPV4="" +# Не слать повтор «Accepted» для той же пары user+IP чаще, чем раз в N секунд (разные port в журнале — один вход). +SSH_ACCEPT_NOTIFY_DEDUP_SEC=5 + +# Временные файлы для отслеживания уже обработанных событий +LAST_SSH_CHECK_FILE="/var/log/last_ssh_check" +LAST_SUDO_CHECK_FILE="/var/log/last_sudo_check" +LAST_SECURITY_EVENTS_FILE="/var/log/last_security_events_check" +LAST_LOGIND_CHECK_FILE="/var/log/last_logind_check" + +# systemd-logind (консоль, графика, seat; SSH тоже создаёт сессии — см. LOGIND_SKIP_REMOTE) +ENABLE_LOGIND_MONITOR=1 +LOGIND_NOTIFY_NEW=1 +LOGIND_NOTIFY_REMOVED=0 +LOGIND_NOTIFY_FAILED=1 +# 1 = не слать дубль Telegram для SSH-сессий logind (Type=ssh или Service=*sshd*; см. logind_session_is_ssh_duplicate_for_notify) +LOGIND_SKIP_REMOTE=1 + +# Эксплуатация / health +PROMETHEUS_TEXTFILE_DIR="" +HEALTHCHECK_STATUS_FILE="" + +# Порог «массового» брутфорса (окно journalctl/auth за N секунд) +BRUTE_WINDOW_SEC=900 +BRUTE_MIN_FAILS=30 +BRUTE_NOTIFY_COOLDOWN_SEC=3600 + +# ============================================ +# НАСТРОЙКИ БЛОКИРОВКИ IP +# ============================================ + +# 1 = мониторинг + автобан IP (ipset); 0 = только мониторинг и уведомления (ipset/iptables не трогаем). +ENABLE_IP_BAN=1 + +BAN_TIME=3600 +MAX_ATTEMPTS=3 +BAN_CHECK_INTERVAL=60 +MONITOR_INTERVAL=10 +BAN_LIST_FILE="/var/log/ssh_bans.txt" + +# Блокировка через ipset (одно правило iptables/ip6tables + таймаут элемента в ядре). +# Имена наборов можно переопределить в /etc/ssh-monitor.conf +IPSET_NAME_V4="ssh_monitor_ban" +IPSET_NAME_V6="ssh_monitor_ban_v6" +IPSET_MAXELEM=1048576 + +WHITELIST_IPS=( + "127.0.0.1" +) + +WHITELIST_SUBNETS=( +) + +declare -A FAIL_COUNTS +declare -A BRUTE_LAST_NOTIFY +declare -A SSH_ACCEPT_LAST_NOTIFY + +CHECK_CONFIG_ONLY=0 +DRY_RUN=0 +# Защита от двойного вызова: при SIGTERM срабатывает trap TERM, затем при выходе — trap EXIT +SHUTDOWN_NOTIFIED=0 +declare -a NOTIFY_CHAIN=() + +trim_value() { + local value="$1" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + echo "$value" +} + +csv_to_array() { + local csv="$1" + local -n out_ref="$2" + local item="" + + out_ref=() + [ -z "$csv" ] && return 0 + + IFS=',' read -r -a raw_items <<< "$csv" + for item in "${raw_items[@]}"; do + item="$(trim_value "$item")" + [ -n "$item" ] && out_ref+=("$item") + done +} + +validate_numeric_or_default() { + local name="$1" + local default_value="$2" + local current_value="${!name:-}" + + if [[ "$current_value" =~ ^[0-9]+$ ]] && [ "$current_value" -gt 0 ]; then + return 0 + fi + + printf 'WARN: Некорректное значение %s="%s", использую %s\n' "$name" "$current_value" "$default_value" >&2 + printf -v "$name" '%s' "$default_value" +} + +validate_report_hour() { + local current_value="${DAILY_REPORT_HOUR:-}" + + if [[ "$current_value" =~ ^[0-9]+$ ]] && [ "$current_value" -ge 0 ] && [ "$current_value" -le 23 ]; then + return 0 + fi + + printf 'WARN: DAILY_REPORT_HOUR="%s" вне диапазона 0..23, использую 9\n' "$current_value" >&2 + DAILY_REPORT_HOUR=9 +} + +load_config() { + local default_whitelist_ips_csv + local default_whitelist_subnets_csv + default_whitelist_ips_csv=$(IFS=','; echo "${WHITELIST_IPS[*]}") + default_whitelist_subnets_csv=$(IFS=','; echo "${WHITELIST_SUBNETS[*]}") + + local cfg_telegram_bot_token="${TELEGRAM_BOT_TOKEN}" + local cfg_telegram_chat_id="${TELEGRAM_CHAT_ID}" + local cfg_log_file="${LOG_FILE}" + local cfg_last_heartbeat_file="${LAST_HEARTBEAT_FILE}" + local cfg_heartbeat_interval_minutes="${HEARTBEAT_INTERVAL_MINUTES:-240}" + local cfg_last_report_file="${LAST_REPORT_FILE}" + local cfg_daily_report_enabled="${DAILY_REPORT_ENABLED:-1}" + local cfg_daily_report_hour="${DAILY_REPORT_HOUR}" + local cfg_daily_report_top_ips="${DAILY_REPORT_TOP_IPS}" + local cfg_daily_report_tz="${DAILY_REPORT_TZ}" + local cfg_notify_tz="${NOTIFY_TZ:-}" + local cfg_server_display_name="${SERVER_DISPLAY_NAME:-}" + local cfg_ssh_accept_notify_dedup_sec="${SSH_ACCEPT_NOTIFY_DEDUP_SEC:-5}" + local cfg_last_ssh_check_file="${LAST_SSH_CHECK_FILE}" + local cfg_last_sudo_check_file="${LAST_SUDO_CHECK_FILE}" + local cfg_last_security_events_file="${LAST_SECURITY_EVENTS_FILE}" + local cfg_last_logind_check_file="${LAST_LOGIND_CHECK_FILE}" + local cfg_enable_logind_monitor="${ENABLE_LOGIND_MONITOR}" + local cfg_logind_notify_new="${LOGIND_NOTIFY_NEW}" + local cfg_logind_notify_removed="${LOGIND_NOTIFY_REMOVED}" + local cfg_logind_notify_failed="${LOGIND_NOTIFY_FAILED}" + local cfg_logind_skip_remote="${LOGIND_SKIP_REMOTE}" + local cfg_prometheus_textfile_dir="${PROMETHEUS_TEXTFILE_DIR}" + local cfg_healthcheck_status_file="${HEALTHCHECK_STATUS_FILE}" + local cfg_brute_window_sec="${BRUTE_WINDOW_SEC}" + local cfg_brute_min_fails="${BRUTE_MIN_FAILS}" + local cfg_brute_notify_cooldown_sec="${BRUTE_NOTIFY_COOLDOWN_SEC}" + local cfg_enable_ip_ban="${ENABLE_IP_BAN:-1}" + local cfg_ban_time="${BAN_TIME}" + local cfg_max_attempts="${MAX_ATTEMPTS}" + local cfg_ban_check_interval="${BAN_CHECK_INTERVAL}" + local cfg_monitor_interval="${MONITOR_INTERVAL}" + local cfg_ban_list_file="${BAN_LIST_FILE}" + local cfg_ipset_name_v4="${IPSET_NAME_V4:-ssh_monitor_ban}" + local cfg_ipset_name_v6="${IPSET_NAME_V6:-ssh_monitor_ban_v6}" + local cfg_ipset_maxelem="${IPSET_MAXELEM:-1048576}" + local cfg_whitelist_ips_csv="${default_whitelist_ips_csv}" + local cfg_whitelist_subnets_csv="${default_whitelist_subnets_csv}" + local cfg_notify_order="${NOTIFY_ORDER:-}" + local cfg_use_sac="${UseSAC:-off}" + local cfg_sac_url="${SAC_URL:-}" + local cfg_sac_api_key="${SAC_API_KEY:-}" + local cfg_sac_spool_dir="${SAC_SPOOL_DIR:-/var/lib/ssh-monitor/sac-spool}" + local cfg_sac_timeout_sec="${SAC_TIMEOUT_SEC:-45}" + local cfg_sac_spool_flush_max="${SAC_SPOOL_FLUSH_MAX_FILES:-50}" + local cfg_sac_spool_max_age_h="${SAC_SPOOL_MAX_AGE_HOURS:-72}" + local cfg_sac_tls_insecure="${SAC_TLS_INSECURE:-0}" + local cfg_config_strict_perms="${CONFIG_STRICT_PERMS:-0}" + 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 + local _strict_peek + _strict_peek="$(ssh_monitor_config_peek CONFIG_STRICT_PERMS "$CONFIG_FILE" "0")" + if ! ssh_monitor_check_config_file_perms "$CONFIG_FILE" "$_strict_peek"; then + exit 1 + fi + if ! ssh_monitor_config_load_file "$CONFIG_FILE"; then + exit 1 + fi + else + printf 'WARN: Конфиг %s не найден, использую значения по умолчанию\n' "$CONFIG_FILE" >&2 + fi + + TELEGRAM_BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-$cfg_telegram_bot_token}" + TELEGRAM_CHAT_ID="${TELEGRAM_CHAT_ID:-$cfg_telegram_chat_id}" + LOG_FILE="${LOG_FILE:-$cfg_log_file}" + LAST_HEARTBEAT_FILE="${LAST_HEARTBEAT_FILE:-$cfg_last_heartbeat_file}" + HEARTBEAT_INTERVAL_MINUTES="${HEARTBEAT_INTERVAL_MINUTES:-$cfg_heartbeat_interval_minutes}" + if ! [[ "${HEARTBEAT_INTERVAL_MINUTES:-240}" =~ ^[0-9]+$ ]] || [ "${HEARTBEAT_INTERVAL_MINUTES:-0}" -lt 1 ]; then + HEARTBEAT_INTERVAL_MINUTES=240 + fi + LAST_REPORT_FILE="${LAST_REPORT_FILE:-$cfg_last_report_file}" + LAST_SSH_CHECK_FILE="${LAST_SSH_CHECK_FILE:-$cfg_last_ssh_check_file}" + LAST_SUDO_CHECK_FILE="${LAST_SUDO_CHECK_FILE:-$cfg_last_sudo_check_file}" + LAST_SECURITY_EVENTS_FILE="${LAST_SECURITY_EVENTS_FILE:-$cfg_last_security_events_file}" + LAST_LOGIND_CHECK_FILE="${LAST_LOGIND_CHECK_FILE:-$cfg_last_logind_check_file}" + ENABLE_LOGIND_MONITOR="${ENABLE_LOGIND_MONITOR:-$cfg_enable_logind_monitor}" + LOGIND_NOTIFY_NEW="${LOGIND_NOTIFY_NEW:-$cfg_logind_notify_new}" + LOGIND_NOTIFY_REMOVED="${LOGIND_NOTIFY_REMOVED:-$cfg_logind_notify_removed}" + LOGIND_NOTIFY_FAILED="${LOGIND_NOTIFY_FAILED:-$cfg_logind_notify_failed}" + LOGIND_SKIP_REMOTE="${LOGIND_SKIP_REMOTE:-$cfg_logind_skip_remote}" + PROMETHEUS_TEXTFILE_DIR="${PROMETHEUS_TEXTFILE_DIR:-$cfg_prometheus_textfile_dir}" + HEALTHCHECK_STATUS_FILE="${HEALTHCHECK_STATUS_FILE:-$cfg_healthcheck_status_file}" + BAN_LIST_FILE="${BAN_LIST_FILE:-$cfg_ban_list_file}" + IPSET_NAME_V4="${IPSET_NAME_V4:-$cfg_ipset_name_v4}" + IPSET_NAME_V6="${IPSET_NAME_V6:-$cfg_ipset_name_v6}" + IPSET_MAXELEM="${IPSET_MAXELEM:-$cfg_ipset_maxelem}" + + DAILY_REPORT_ENABLED="$(trim_value "${DAILY_REPORT_ENABLED:-$cfg_daily_report_enabled}")" + DAILY_REPORT_HOUR="${DAILY_REPORT_HOUR:-$cfg_daily_report_hour}" + DAILY_REPORT_TOP_IPS="${DAILY_REPORT_TOP_IPS:-$cfg_daily_report_top_ips}" + DAILY_REPORT_TZ="$(trim_value "${DAILY_REPORT_TZ:-$cfg_daily_report_tz}")" + NOTIFY_TZ="$(trim_value "${NOTIFY_TZ:-$cfg_notify_tz}")" + SERVER_DISPLAY_NAME="$(trim_value "${SERVER_DISPLAY_NAME:-$cfg_server_display_name}")" + SSH_ACCEPT_NOTIFY_DEDUP_SEC="$(trim_value "${SSH_ACCEPT_NOTIFY_DEDUP_SEC:-$cfg_ssh_accept_notify_dedup_sec}")" + if ! [[ "${SSH_ACCEPT_NOTIFY_DEDUP_SEC:-5}" =~ ^[0-9]+$ ]]; then + SSH_ACCEPT_NOTIFY_DEDUP_SEC=5 + fi + BRUTE_WINDOW_SEC="${BRUTE_WINDOW_SEC:-$cfg_brute_window_sec}" + BRUTE_MIN_FAILS="${BRUTE_MIN_FAILS:-$cfg_brute_min_fails}" + BRUTE_NOTIFY_COOLDOWN_SEC="${BRUTE_NOTIFY_COOLDOWN_SEC:-$cfg_brute_notify_cooldown_sec}" + ENABLE_IP_BAN="${ENABLE_IP_BAN:-$cfg_enable_ip_ban}" + BAN_TIME="${BAN_TIME:-$cfg_ban_time}" + MAX_ATTEMPTS="${MAX_ATTEMPTS:-$cfg_max_attempts}" + BAN_CHECK_INTERVAL="${BAN_CHECK_INTERVAL:-$cfg_ban_check_interval}" + MONITOR_INTERVAL="${MONITOR_INTERVAL:-$cfg_monitor_interval}" + + local whitelist_ips_csv="" + local whitelist_subnets_csv="" + if [ -f "$CONFIG_FILE" ]; then + whitelist_ips_csv="$(ssh_monitor_config_peek WHITELIST_IPS "$CONFIG_FILE" "$cfg_whitelist_ips_csv")" + whitelist_subnets_csv="$(ssh_monitor_config_peek WHITELIST_SUBNETS "$CONFIG_FILE" "$cfg_whitelist_subnets_csv")" + else + whitelist_ips_csv="$cfg_whitelist_ips_csv" + whitelist_subnets_csv="$cfg_whitelist_subnets_csv" + fi + unset -v WHITELIST_IPS WHITELIST_SUBNETS 2>/dev/null || true + WHITELIST_IPS=() + WHITELIST_SUBNETS=() + csv_to_array "$whitelist_ips_csv" WHITELIST_IPS + csv_to_array "$whitelist_subnets_csv" WHITELIST_SUBNETS + + NOTIFY_ORDER="$(trim_value "${NOTIFY_ORDER:-$cfg_notify_order}")" + UseSAC="$(trim_value "${UseSAC:-$cfg_use_sac}")" + SAC_URL="$(trim_value "${SAC_URL:-$cfg_sac_url}")" + SAC_API_KEY="$(trim_value "${SAC_API_KEY:-$cfg_sac_api_key}")" + SAC_SPOOL_DIR="$(trim_value "${SAC_SPOOL_DIR:-$cfg_sac_spool_dir}")" + SAC_TIMEOUT_SEC="$(trim_value "${SAC_TIMEOUT_SEC:-$cfg_sac_timeout_sec}")" + SAC_SPOOL_FLUSH_MAX_FILES="$(trim_value "${SAC_SPOOL_FLUSH_MAX_FILES:-$cfg_sac_spool_flush_max}")" + SAC_SPOOL_MAX_AGE_HOURS="$(trim_value "${SAC_SPOOL_MAX_AGE_HOURS:-$cfg_sac_spool_max_age_h}")" + SAC_TLS_INSECURE="$(trim_value "${SAC_TLS_INSECURE:-$cfg_sac_tls_insecure}")" + CONFIG_STRICT_PERMS="$(trim_value "${CONFIG_STRICT_PERMS:-$cfg_config_strict_perms}")" + MAIL_SMTP_HOST="$(trim_value "${MAIL_SMTP_HOST:-$cfg_mail_smtp_host}")" + MAIL_SMTP_PORT="${MAIL_SMTP_PORT:-$cfg_mail_smtp_port}" + MAIL_SMTP_USER="$(trim_value "${MAIL_SMTP_USER:-$cfg_mail_smtp_user}")" + MAIL_SMTP_PASSWORD="${MAIL_SMTP_PASSWORD:-$cfg_mail_smtp_password}" + MAIL_FROM="$(trim_value "${MAIL_FROM:-$cfg_mail_from}")" + MAIL_TO="$(trim_value "${MAIL_TO:-$cfg_mail_to}")" + MAIL_SMTP_STARTTLS="${MAIL_SMTP_STARTTLS:-$cfg_mail_smtp_starttls}" + MAIL_SMTP_SSL="${MAIL_SMTP_SSL:-$cfg_mail_smtp_ssl}" + + local _eib + _eib="$(trim_value "${ENABLE_IP_BAN:-1}")" + case "${_eib,,}" in + 0 | no | off | false) ENABLE_IP_BAN=0 ;; + *) ENABLE_IP_BAN=1 ;; + esac +} + +# Дата/время для ежедневного отчёта (час DAILY_REPORT_HOUR и «сегодня» в LAST_REPORT_FILE). +report_date() { + if [ -n "${DAILY_REPORT_TZ:-}" ]; then + TZ="$DAILY_REPORT_TZ" date "$@" + else + date "$@" + fi +} + +# Время в текстах уведомлений (Telegram и др.): явная зона, иначе как у ежедневного отчёта, иначе как у процесса. +notification_date() { + if [ -n "${NOTIFY_TZ:-}" ]; then + TZ="$NOTIFY_TZ" date "$@" + elif [ -n "${DAILY_REPORT_TZ:-}" ]; then + TZ="$DAILY_REPORT_TZ" date "$@" + else + date "$@" + fi +} + +is_telegram_configured() { + [ -n "${TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${TELEGRAM_CHAT_ID:-}" ] +} + +is_email_configured() { + [ -n "${MAIL_SMTP_HOST:-}" ] && [ -n "${MAIL_FROM:-}" ] && [ -n "${MAIL_TO:-}" ] && command -v python3 >/dev/null 2>&1 +} + +# Заполняет NOTIFY_CHAIN: при пустом NOTIFY_ORDER — только реально настроенные каналы (telegram → email). +# При непустом NOTIFY_ORDER — ровно указанный список (имена каналов), без добавления «лишних»; notify_send шлёт во ВСЕ каналы из списка. +refresh_notify_chain() { + NOTIFY_CHAIN=() + local order_raw + order_raw="$(trim_value "${NOTIFY_ORDER:-}")" + + if [ -z "$order_raw" ]; then + if is_telegram_configured; then + NOTIFY_CHAIN+=(telegram) + fi + if is_email_configured; then + NOTIFY_CHAIN+=(email) + fi + return 0 + fi + + local -a raw_toks=() + csv_to_array "$order_raw" raw_toks + local tok norm + for tok in "${raw_toks[@]}"; do + norm="${tok,,}" + case "$norm" in + telegram | tg) NOTIFY_CHAIN+=(telegram) ;; + email | mail) NOTIFY_CHAIN+=(email) ;; + *) + printf 'WARN: NOTIFY_ORDER: неизвестный токен «%s», пропускаю\n' "$tok" >&2 + ;; + esac + done +} + +notify_chain_human() { + if [ "${#NOTIFY_CHAIN[@]}" -eq 0 ]; then + printf '%s' "нет активных каналов" + return 0 + fi + (IFS=','; printf '%s' "${NOTIFY_CHAIN[*]}") +} + +write_state_file() { + local path="$1" content="$2" + ssh_monitor_secure_file "$path" + printf '%s' "$content" >"$path" +} + +ssh_monitor_audit_state_paths() { + local path + for path in \ + "${SAC_SPOOL_DIR:-}" \ + "${SAC_FAIL_COUNT_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:-}" \ + "${HEALTHCHECK_STATUS_FILE:-}" \ + "/etc/ssh_monitor_whitelist.txt"; do + [ -n "$path" ] || continue + if [ -d "$path" ]; then + ssh_monitor_warn_path_perms "$path" "каталог state" 700 + elif [ -e "$path" ]; then + ssh_monitor_warn_path_perms "$path" "файл state" 600 + fi + done +} + +validate_config() { + validate_report_hour + validate_numeric_or_default "BAN_TIME" "3600" + validate_numeric_or_default "MAX_ATTEMPTS" "3" + validate_numeric_or_default "BAN_CHECK_INTERVAL" "60" + validate_numeric_or_default "MONITOR_INTERVAL" "10" + validate_numeric_or_default "DAILY_REPORT_TOP_IPS" "5" + validate_numeric_or_default "BRUTE_WINDOW_SEC" "900" + validate_numeric_or_default "BRUTE_MIN_FAILS" "30" + validate_numeric_or_default "BRUTE_NOTIFY_COOLDOWN_SEC" "3600" + validate_numeric_or_default "MAIL_SMTP_PORT" "587" + validate_numeric_or_default "IPSET_MAXELEM" "1048576" + if [ "${ENABLE_IP_BAN:-1}" = "1" ] && [ "${CHECK_CONFIG_ONLY:-0}" != "1" ] && [ "${DRY_RUN:-0}" != "1" ]; then + if ! command -v ipset >/dev/null 2>&1; then + printf '%s\n' 'Включён автобан IP (ENABLE_IP_BAN=1): требуется утилита ipset (например: apt install ipset)' >&2 + exit 1 + fi + fi + local sac_mode + sac_mode="$(sac_normalize_mode "${UseSAC:-off}")" + case "$sac_mode" in + off) ;; + exclusive | dual | fallback) + if ! sac_is_configured; then + printf '%s\n' "UseSAC=${sac_mode}: задайте SAC_URL и SAC_API_KEY" >&2 + exit 1 + fi + ;; + *) + printf '%s\n' "Неверный UseSAC=\"${UseSAC:-}\"" >&2 + exit 1 + ;; + esac + + refresh_notify_chain + if [ "$sac_mode" = "exclusive" ]; then + if [ "${CHECK_CONFIG_ONLY:-0}" != "1" ] && [ "${CHECK_SAC_ONLY:-0}" != "1" ] && [ "${DRY_RUN:-0}" != "1" ]; then + if ! sac_check_health; then + printf '%s\n' 'UseSAC=exclusive: SAC /health недоступен' >&2 + exit 1 + fi + fi + elif [ "${#NOTIFY_CHAIN[@]}" -eq 0 ]; then + printf '%s\n' 'Не настроен ни один канал отправки оповещений' >&2 + exit 1 + fi + if ! ssh_monitor_validate_config_values; then + exit 1 + fi + if command -v sac_warn_tls_insecure >/dev/null 2>&1; then + sac_warn_tls_insecure + fi +} + +# ============================================ +# УТИЛИТЫ: статистика auth.log за 24 часа (Python) +# ============================================ + +stats_auth_log_24h_json() { + if command -v python3 >/dev/null 2>&1; then + python3 - "$@" <<'PY' +import datetime, gzip, json, re, sys, collections +from pathlib import Path + +try: + top_n = int(sys.argv[1]) +except Exception: + top_n = 5 + +NOW = datetime.datetime.now() +CUTOFF = NOW - datetime.timedelta(hours=24) +MONTHS = { + "Jan": 1, "Feb": 2, "Mar": 3, "Apr": 4, "May": 5, "Jun": 6, + "Jul": 7, "Aug": 8, "Sep": 9, "Oct": 10, "Nov": 11, "Dec": 12, +} + +paths = [] +for p in (Path("/var/log/auth.log"), Path("/var/log/auth.log.1")): + if p.is_file(): + paths.append(p) +try: + for p in sorted(Path("/var/log").glob("auth.log.*.gz")): + paths.append(p) +except Exception: + pass + +accepted = failed = sudo_cmd = 0 +failed_by_ip = collections.Counter() + +line_re = re.compile( + r"^(?P [A-Za-z]{3})\s+(?P \d{1,2})\s+(?P \d{2}:\d{2}:\d{2})\s+" + r"(?P.+)$" +) + +def open_any(path): + if str(path).endswith(".gz"): + return gzip.open(path, "rt", errors="ignore") + return path.open("r", errors="ignore") + +def parse_ts(mon: str, day: int, hms: str): + m = MONTHS.get(mon) + if not m: + return None + try: + h, mi, s = [int(x) for x in hms.split(":")] + except Exception: + return None + year = NOW.year + dt = datetime.datetime(year, m, int(day), h, mi, s) + if dt > NOW + datetime.timedelta(days=2): + dt = dt.replace(year=year - 1) + if dt < NOW - datetime.timedelta(days=400): + dt = dt.replace(year=year + 1) + return dt + +ip_from_failed = re.compile(r" from ([^ ]+) port ") +ip_from_accepted = re.compile(r" from ([^ ]+) port ") + +for path in paths: + try: + with open_any(path) as fh: + for raw in fh: + m = line_re.match(raw) + if not m: + continue + dt = parse_ts(m.group("mon"), int(m.group("day")), m.group("hms")) + if not dt or dt < CUTOFF: + continue + rest = m.group("rest") + if "sshd" in rest and "Accepted" in rest: + accepted += 1 + if "sshd" in rest and "Failed password" in rest: + failed += 1 + im = ip_from_failed.search(rest) + if im: + failed_by_ip[im.group(1)] += 1 + if "sudo" in rest and "COMMAND" in rest: + sudo_cmd += 1 + except FileNotFoundError: + continue + except Exception: + continue + +top = [f"{ip}:{cnt}" for ip, cnt in failed_by_ip.most_common(top_n)] +print(json.dumps({"accepted": accepted, "failed": failed, "sudo": sudo_cmd, "top": top})) +PY + else + echo '{"accepted":0,"failed":0,"sudo":0,"top":[]}' + fi +} + +# ============================================ +# ЗАПИСЬ В ЛОГ +# ============================================ + +write_log() { + local message="$1" + local timestamp + timestamp=$(date '+%Y-%m-%d %H:%M:%S') + if [ "${CHECK_CONFIG_ONLY:-0}" = "1" ]; then + printf '%s - %s\n' "$timestamp" "$message" + return 0 + fi + if [ "${DRY_RUN:-0}" = "1" ] && [ ! -w "$(dirname "$LOG_FILE")" ] 2>/dev/null; then + printf '%s - %s\n' "$timestamp" "$message" + return 0 + fi + echo "${timestamp} - ${message}" >>"$LOG_FILE" + echo "${timestamp} - ${message}" +} + +# ============================================ +# Подпись сервера в текстах уведомлений +# ============================================ + +# Для Telegram и отчётов: «hostname (IPv4)» или SERVER_DISPLAY_NAME (+ IPv4); без IPv4 — только имя. +server_display_name_with_ip() { + local hn ip + if [ -n "${SERVER_DISPLAY_NAME:-}" ]; then + hn="${SERVER_DISPLAY_NAME}" + else + hn=$(hostname 2>/dev/null | tr -d '\r\n') + [ -z "$hn" ] && hn="(unknown)" + fi + ip="" + if command -v ip >/dev/null 2>&1; then + ip=$(ip -4 route get 1.1.1.1 2>/dev/null | awk '/\bsrc\b/ {for (i = 1; i < NF; i++) if ($i == "src") { print $(i + 1); exit }}') + [ -z "$ip" ] && ip=$(ip -4 route get 8.8.8.8 2>/dev/null | awk '/\bsrc\b/ {for (i = 1; i < NF; i++) if ($i == "src") { print $(i + 1); exit }}') + fi + if [ -z "$ip" ] && command -v hostname >/dev/null 2>&1; then + if hostname -I >/dev/null 2>&1; then + ip=$(hostname -I 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | grep -Ev '^(127\.|0\.0\.0\.0)$' | head -n1) + fi + fi + if [ -n "$ip" ]; then + printf '%s (%s)' "$hn" "$ip" + else + printf '%s' "$hn" + fi +} + +# Вставляет «🖥️ Сервер: …» сразу после первой строки (заголовка), если строки ещё нет. +message_ensure_server_line() { + local msg="$1" + if [[ "$msg" == *'🖥️ Сервер:'* ]]; then + printf '%s' "$msg" + return 0 + fi + local first rest + first="${msg%%$'\n'*}" + if [ "$first" = "$msg" ]; then + printf '%s\n🖥️ Сервер: %s' "$first" "$(server_display_name_with_ip)" + else + rest="${msg#*$'\n'}" + printf '%s\n🖥️ Сервер: %s\n%s' "$first" "$(server_display_name_with_ip)" "$rest" + fi +} + +# ============================================ +# УВЕДОМЛЕНИЯ: telegram / email — по NOTIFY_CHAIN во все перечисленные каналы. +# ============================================ + +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 +} + +notification_source_agent_plain() { + printf '📡 Оповещение: агент (ssh-monitor %s)' "${SSH_MONITOR_VERSION:-unknown}" +} + +append_agent_notification_source() { + local msg="$1" + if printf '%s' "$msg" | grep -Fq '📡 Оповещение:'; then + printf '%s' "$msg" + return 0 + fi + local line + line="$(notification_source_agent_plain)" + if [ -z "$msg" ]; then + printf '%s' "$line" + else + msg="${msg%"${msg##*[![:space:]]}"}" + printf '%s\n\n%s' "$msg" "$line" + fi +} + +send_telegram_raw() { + local message="$1" + if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then + return 1 + fi + local url="https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" + local body + if ! body=$(curl -fsS -X POST "$url" \ + --data-urlencode "chat_id=${TELEGRAM_CHAT_ID}" \ + --data-urlencode "text=${message}" \ + --data-urlencode "parse_mode=HTML" \ + --max-time 10 2>/dev/null); then + return 1 + fi + if echo "$body" | grep -q '"ok":true'; then + return 0 + fi + return 1 +} + +sac_mirror_notify_message() { + local message="$1" + local mode + mode="$(sac_normalize_mode "${UseSAC:-off}")" + [ "$mode" = "off" ] && return 0 + local summary + summary="$(printf '%s' "$message" | tr '\n' ' ' | head -c 400)" + sac_send_event "agent.notification" "info" "SSH Monitor" "$summary" "" || return 1 +} + +notify_send() { + local message="$1" + message="$(message_ensure_server_line "$message")" + message="$(append_agent_notification_source "$message")" + if [ "${DRY_RUN:-0}" = "1" ]; then + printf '[DRY-RUN notify] %s\n' "$(printf '%s' "$message" | tr '\n' ' ')" >&2 + return 0 + fi + + local sac_mode + sac_mode="$(sac_normalize_mode "${UseSAC:-off}")" + if [ "${NOTIFY_SKIP_SAC_MIRROR:-0}" != "1" ]; then + case "$sac_mode" in + exclusive) + if sac_mirror_notify_message "$message"; then + return 0 + fi + write_log "WARN: UseSAC=exclusive: SAC не принял событие" + return 1 + ;; + dual) + sac_mirror_notify_message "$message" || true + ;; + fallback) + if sac_mirror_notify_message "$message"; then + return 0 + fi + ;; + esac + fi + + local any_ok=0 + local ch + for ch in "${NOTIFY_CHAIN[@]}"; do + case "$ch" in + telegram) + if send_telegram_raw "$message"; then + any_ok=1 + else + write_log "WARN: канал telegram: не удалось отправить уведомление" + fi + ;; + email) + if send_email_raw "$message"; then + any_ok=1 + else + write_log "WARN: канал email: не удалось отправить уведомление" + fi + ;; + esac + done + + if [ "$any_ok" -eq 1 ]; then + return 0 + fi + + write_log "WARN: не удалось доставить уведомление ни в один канал ($(notify_chain_human))" + return 1 +} + +# ============================================ +# IP / подсети (IPv4 CIDR; IPv6 — только точное совпадение в WHITELIST_IPS) +# ============================================ + +ip_to_num() { + local ip="$1" + local a b c d + IFS=. read -r a b c d <<<"$ip" + echo "$((a * 256 ** 3 + b * 256 ** 2 + c * 256 + d))" +} + +ip_in_subnet() { + local ip="$1" + local subnet="$2" + + [[ "$ip" == *:* ]] && return 1 + + local network="${subnet%/*}" + local mask="${subnet#*/}" + + local ip_num + local network_num + ip_num=$(ip_to_num "$ip") + network_num=$(ip_to_num "$network") + + local mask_num=$((0xffffffff << (32 - mask) & 0xffffffff)) + + if [ $((ip_num & mask_num)) -eq $((network_num & mask_num)) ]; then + return 0 + fi + return 1 +} + +is_whitelisted() { + local ip="$1" + + for whitelist_ip in "${WHITELIST_IPS[@]}"; do + if [ "$ip" = "$whitelist_ip" ]; then + return 0 + fi + done + + [[ "$ip" == *:* ]] && return 1 + + for subnet in "${WHITELIST_SUBNETS[@]}"; do + if ip_in_subnet "$ip" "$subnet"; then + return 0 + fi + done + + return 1 +} + +# ============================================ +# ipset + одно правило iptables/ip6tables (без per-IP правил) +# ============================================ +# Таймаут бана задаётся на элементе ipset: ядро само удаляет IP по истечении +# времени. Файл BAN_LIST_FILE — для отчётов и восстановления после перезагрузки. + +is_ipv6() { + [[ "$1" == *:* ]] +} + +# Верхняя граница timeout для ipset (секунды), см. ipset(8). +IPSET_TIMEOUT_MAX=2147483 + +run_priv() { + if [ "${DRY_RUN:-0}" = "1" ]; then + write_log "DRY-RUN: $*" + return 0 + fi + if [ "$EUID" -eq 0 ]; then + "$@" + else + sudo "$@" + fi +} + +ipset_timeout_clamp() { + local t="${1:-$BAN_TIME}" + if ! [[ "$t" =~ ^[0-9]+$ ]]; then + t="$BAN_TIME" + fi + if [ "$t" -gt "$IPSET_TIMEOUT_MAX" ]; then + echo "$IPSET_TIMEOUT_MAX" + else + echo "$t" + fi +} + +ensure_ipset_and_iptables() { + if [ "${ENABLE_IP_BAN:-1}" != "1" ]; then + return 0 + fi + if [ "${DRY_RUN:-0}" = "1" ]; then + return 0 + fi + if ! command -v ipset >/dev/null 2>&1; then + write_log "CRIT: ipset не найден; установите: apt install ipset" + return 1 + fi + + local maxt="$IPSET_TIMEOUT_MAX" + run_priv ipset -exist create "$IPSET_NAME_V4" hash:ip timeout "$maxt" maxelem "$IPSET_MAXELEM" || { + write_log "CRIT: не удалось создать ipset $IPSET_NAME_V4" + return 1 + } + + if command -v ip6tables >/dev/null 2>&1; then + run_priv ipset -exist create "$IPSET_NAME_V6" hash:ip family inet6 timeout "$maxt" maxelem "$IPSET_MAXELEM" || \ + write_log "WARN: не удалось создать ipset $IPSET_NAME_V6 (IPv6 бан отключён)" + fi + + if ! run_priv iptables -C INPUT -m set --match-set "$IPSET_NAME_V4" src -j DROP 2>/dev/null; then + run_priv iptables -I INPUT 1 -m set --match-set "$IPSET_NAME_V4" src -j DROP + write_log "Добавлено iptables: -m set --match-set $IPSET_NAME_V4 src -j DROP" + fi + + if command -v ip6tables >/dev/null 2>&1 && ipset list "$IPSET_NAME_V6" >/dev/null 2>&1; then + if ! run_priv ip6tables -C INPUT -m set --match-set "$IPSET_NAME_V6" src -j DROP 2>/dev/null; then + run_priv ip6tables -I INPUT 1 -m set --match-set "$IPSET_NAME_V6" src -j DROP + write_log "Добавлено ip6tables: -m set --match-set $IPSET_NAME_V6 src -j DROP" + fi + fi + + save_firewall_state + return 0 +} + +save_firewall_state() { + if [ "${DRY_RUN:-0}" = "1" ]; then + return 0 + fi + if [ ! -d /etc/iptables ]; then + return 0 + fi + if command -v ipset >/dev/null 2>&1; then + run_priv ipset save -f /etc/iptables/ipset.rules 2>/dev/null || true + fi + if command -v iptables-save >/dev/null 2>&1; then + run_priv iptables-save >/etc/iptables/rules.v4 2>/dev/null || true + fi + if command -v ip6tables-save >/dev/null 2>&1; then + run_priv ip6tables-save >/etc/iptables/rules.v6 2>/dev/null || true + fi +} + +do_ipset_add_ban() { + local ip="$1" + local t + t=$(ipset_timeout_clamp "${2:-$BAN_TIME}") + + if is_ipv6 "$ip"; then + if ! ipset list "$IPSET_NAME_V6" >/dev/null 2>&1; then + write_log "WARN: IPv6 $ip — набор $IPSET_NAME_V6 недоступен, пропуск" + return 1 + fi + run_priv ipset -exist del "$IPSET_NAME_V6" "$ip" 2>/dev/null || true + run_priv ipset add "$IPSET_NAME_V6" "$ip" timeout "$t" || return 1 + else + run_priv ipset -exist del "$IPSET_NAME_V4" "$ip" 2>/dev/null || true + run_priv ipset add "$IPSET_NAME_V4" "$ip" timeout "$t" || return 1 + fi + return 0 +} + +do_ipset_del_ban() { + local ip="$1" + if is_ipv6 "$ip"; then + run_priv ipset -exist del "$IPSET_NAME_V6" "$ip" 2>/dev/null || true + else + run_priv ipset -exist del "$IPSET_NAME_V4" "$ip" 2>/dev/null || true + fi +} + +is_ip_blocked() { + local ip="$1" + if [ "${ENABLE_IP_BAN:-1}" != "1" ]; then + return 1 + fi + if [ "${DRY_RUN:-0}" = "1" ]; then + return 1 + fi + if is_ipv6 "$ip"; then + ipset list "$IPSET_NAME_V6" >/dev/null 2>&1 || return 1 + run_priv ipset test "$IPSET_NAME_V6" "$ip" >/dev/null 2>&1 + else + run_priv ipset test "$IPSET_NAME_V4" "$ip" >/dev/null 2>&1 + fi +} + +block_ip() { + local ip="$1" + local attempts="$2" + + if is_whitelisted "$ip"; then + write_log "Попытка блокировки whitelist IP $ip - игнорируем" + return 0 + fi + + if is_ip_blocked "$ip"; then + return 0 + fi + + if [ "${ENABLE_IP_BAN:-1}" != "1" ]; then + local ban_time_hours=$((BAN_TIME / 3600)) + local message=" ⚠️ ЛИМИТ НЕУДАЧНЫХ SSH (автобан отключён в конфиге) "$'\n' + message="${message}🌐 IP: ${ip}"$'\n' + message="${message}👤 Попыток: ${attempts} (порог ${MAX_ATTEMPTS}; ENABLE_IP_BAN=0)"$'\n' + message="${message}ℹ️ ipset/firewall не меняются. При ENABLE_IP_BAN=1 бан был бы ~${ban_time_hours} ч."$'\n' + message="${message}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')" + SAC_D_IP="$ip" SAC_D_ATTEMPT="$attempts" SAC_D_MAX="$MAX_ATTEMPTS" \ + notify_or_sac "ssh.ip.bruteforce.threshold" "warning" "Лимит SSH без бана" "${ip}: ${attempts} попыток" "$message" + write_log "Порог неудачных SSH для $ip ($attempts попыток), автобан отключён (ENABLE_IP_BAN=0)" + return 0 + fi + + local ban_until=$(($(date '+%s') + BAN_TIME)) + local t_use + t_use=$(ipset_timeout_clamp "$BAN_TIME") + + if ! do_ipset_add_ban "$ip" "$t_use"; then + write_log "CRIT: не удалось добавить $ip в ipset" + return 1 + fi + + ssh_monitor_secure_file "$BAN_LIST_FILE" 2>/dev/null || true + echo "$ip|$ban_until|$attempts|$(date '+%Y-%m-%d %H:%M:%S')" >>"$BAN_LIST_FILE" + + save_firewall_state + + local ban_time_hours=$((BAN_TIME / 3600)) + local message=" 🚫 IP ЗАБЛОКИРОВАН АВТОМАТИЧЕСКИ "$'\n' + message="${message}🌐 IP: ${ip}"$'\n' + message="${message}📊 Неудачных попыток: ${attempts}"$'\n' + message="${message}⏱️ Блокировка на: ${ban_time_hours} час(ов) (ipset timeout)"$'\n' + message="${message}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')" + SAC_D_IP="$ip" SAC_D_ATTEMPT="$attempts" \ + notify_or_sac "ssh.ip.banned" "high" "IP заблокирован" "${ip} (${attempts} попыток)" "$message" + write_log "AUTO-BAN: IP $ip в ipset на $ban_time_hours ч. ($attempts попыток)" + + # Один IP — одна строка: после истечения ipset-таймаута is_ip_blocked=0, а в файле + # бан ещё «действует» → повторный бан иначе дублировал бы строки. + ban_list_rewrite_deduped 0 +} + +# Ядро само удаляет элемент ipset по timeout; файл — учёт. Дубликаты возникают, +# если ipset уже сбросил IP, а ban_until в файле ещё в будущем (повторный бан). +# По каждому IP оставляем одну запись с максимальным ban_until; просроченные убираем. +# $1=1 — не писать в лог (внутренний вызов из цикла проверки). +ban_list_rewrite_deduped() { + local suppress_log="${1:-0}" + local current_time + current_time=$(date '+%s') + + if [ ! -f "$BAN_LIST_FILE" ]; then + return 0 + fi + + declare -A best_until=() + declare -A best_line=() + + local ip ban_until attempts ban_time + while IFS='|' read -r ip ban_until attempts ban_time || [ -n "${ip:-}" ]; do + [ -z "${ip:-}" ] || [ -z "${ban_until:-}" ] && continue + [ "$current_time" -ge "$ban_until" ] && continue + + if [ -z "${best_until[$ip]:-}" ] || [ "$ban_until" -gt "${best_until[$ip]}" ]; then + best_until[$ip]=$ban_until + best_line[$ip]="${ip}|${ban_until}|${attempts}|${ban_time}" + fi + done <"$BAN_LIST_FILE" + + local temp_file + temp_file=$(mktemp) + local sorted_ips k + sorted_ips=$(printf '%s\n' "${!best_line[@]}" | LC_ALL=C sort) + while IFS= read -r k; do + [ -z "$k" ] && continue + echo "${best_line[$k]}" >>"$temp_file" + done <<<"$sorted_ips" + + if [ ! -s "$temp_file" ]; then + if [ -s "$BAN_LIST_FILE" ]; then + : >"$BAN_LIST_FILE" + if [ "$suppress_log" != "1" ]; then + write_log "Бан-лист: все записи просрочены — файл очищен" + fi + fi + rm -f "$temp_file" + return 0 + fi + + if cmp -s "$BAN_LIST_FILE" "$temp_file" 2>/dev/null; then + rm -f "$temp_file" + return 0 + fi + # Тот же набор активных банов, но другой порядок строк — без лишней перезаписи/лога. + if cmp -s <(LC_ALL=C sort "$BAN_LIST_FILE") <(LC_ALL=C sort "$temp_file") 2>/dev/null; then + rm -f "$temp_file" + return 0 + fi + + local old_lines new_lines + old_lines=$(wc -l <"$BAN_LIST_FILE" | tr -d ' ' || echo 0) + new_lines=$(wc -l <"$temp_file" | tr -d ' ' || echo 0) + mv "$temp_file" "$BAN_LIST_FILE" + + if [ "$suppress_log" != "1" ]; then + write_log "Бан-лист нормализован: строк ${old_lines} → ${new_lines} (дубликаты IP объединены, просроченные удалены)" + fi + return 0 +} + +# Ядро само удаляет элемент ipset по timeout; здесь только подчистка файла учёта +# (без Telegram на каждый IP — иначе спам). +unblock_expired_ips() { + [ "${ENABLE_IP_BAN:-1}" = "1" ] || return 0 + ban_list_rewrite_deduped 0 +} + +load_existing_bans() { + [ "${ENABLE_IP_BAN:-1}" = "1" ] || return 0 + if [ ! -f "$BAN_LIST_FILE" ]; then + return + fi + + local current_time + current_time=$(date '+%s') + declare -A best_until=() + declare -A best_line=() + + local ip ban_until attempts ban_time + while IFS='|' read -r ip ban_until attempts ban_time || [ -n "${ip:-}" ]; do + [ -z "${ip:-}" ] || [ -z "${ban_until:-}" ] && continue + [ "$current_time" -lt "$ban_until" ] || continue + + if [ -z "${best_until[$ip]:-}" ] || [ "$ban_until" -gt "${best_until[$ip]}" ]; then + best_until[$ip]=$ban_until + best_line[$ip]="${ip}|${ban_until}|${attempts}|${ban_time}" + fi + done <"$BAN_LIST_FILE" + + local temp_file + temp_file=$(mktemp) + local sorted_ips k remain t_use + sorted_ips=$(printf '%s\n' "${!best_line[@]}" | LC_ALL=C sort) + while IFS= read -r k; do + [ -z "$k" ] && continue + IFS='|' read -r ip ban_until attempts ban_time <<<"${best_line[$k]}" + remain=$((ban_until - current_time)) + t_use=$(ipset_timeout_clamp "$remain") + if ! is_ip_blocked "$ip"; then + do_ipset_add_ban "$ip" "$t_use" || true + write_log "Восстановлен ipset-бан $ip на $((remain / 60)) мин (после перезапуска)" + fi + echo "${best_line[$k]}" >>"$temp_file" + done <<<"$sorted_ips" + + mv "$temp_file" "$BAN_LIST_FILE" + save_firewall_state +} + +verify_ban_list_vs_ipset() { + [ "${ENABLE_IP_BAN:-1}" = "1" ] || return 0 + [ ! -f "$BAN_LIST_FILE" ] && return 0 + local current_epoch + current_epoch=$(date '+%s') + while IFS='|' read -r ip ban_until _rest; do + [ -z "$ip" ] || [ -z "$ban_until" ] && continue + if [ "$current_epoch" -lt "$ban_until" ]; then + if ! is_ip_blocked "$ip"; then + write_log "AUDIT: рассинхрон — IP $ip в бан-листе, но нет в ipset; восстанавливаю" + local remain=$((ban_until - current_epoch)) + do_ipset_add_ban "$ip" "$(ipset_timeout_clamp "$remain")" || true + fi + fi + done <"$BAN_LIST_FILE" +} + +ipset_active_ban_count() { + local n n6 + n=$(ipset list "$IPSET_NAME_V4" 2>/dev/null | awk '/^Number of entries:/{print $4; exit}') + n="${n:-0}" + n6=0 + if ipset list "$IPSET_NAME_V6" >/dev/null 2>&1; then + n6=$(ipset list "$IPSET_NAME_V6" 2>/dev/null | awk '/^Number of entries:/{print $4; exit}') + fi + n6="${n6:-0}" + echo $((n + n6)) +} + +load_whitelist_from_file() { + local whitelist_file="/etc/ssh_monitor_whitelist.txt" + + if [ -f "$whitelist_file" ]; then + ssh_monitor_warn_path_perms "$whitelist_file" "whitelist" 600 + write_log "Загрузка белого списка из $whitelist_file" + + while IFS= read -r line || [ -n "$line" ]; do + [[ "$line" =~ ^#.*$ ]] && continue + [[ -z "$line" ]] && continue + + if [[ "$line" == *"/"* ]]; then + WHITELIST_SUBNETS+=("$line") + write_log "Добавлена подсеть в белый список: $line" + else + WHITELIST_IPS+=("$line") + write_log "Добавлен IP в белый список: $line" + fi + done <"$whitelist_file" + fi +} + +# ============================================ +# Активные SSH-сессии (IPv4/IPv6 в скобках) +# ============================================ + +get_active_ssh_sessions() { + local out="" + local line user tty login_time ip + while IFS= read -r line; do + [[ -z "$line" ]] && continue + user=$(awk '{print $1}' <<<"$line") + tty=$(awk '{print $2}' <<<"$line") + login_time=$(awk '{print $3, $4}' <<<"$line") + ip=$(sed -nE 's/.*\(([^)]+)\).*/\1/p' <<<"$line") + ip="${ip:-local}" + out+=" 👤 ${user} | ${tty} | с ${login_time} | 🌐 ${ip}"$'\n' + done < <(who 2>/dev/null || true) + printf '%s' "$out" +} + +extract_ssh_client_ip() { + local line="$1" + local ip="" + ip=$(sed -nE 's/.* from ([^ ]+) port .*/\1/p' <<<"$line") + if [ -z "$ip" ]; then + ip=$(sed -nE 's/.* from ([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+).*/\1/p' <<<"$line") + fi + printf '%s' "$ip" +} + +# ============================================ +# Мониторинг SSH +# ============================================ + +monitor_ssh() { + local last_check="0" + if [ -f "$LAST_SSH_CHECK_FILE" ]; then + last_check="$(<"$LAST_SSH_CHECK_FILE")" + fi + local current_time + current_time=$(date '+%s') + + local ssh_logs="" + if command -v journalctl >/dev/null 2>&1; then + # -u ssh и -u sshd вместе часто дают две одинаковые строки Accepted; _COMM=sshd — один поток. + ssh_logs=$(journalctl _COMM=sshd --since="@${last_check}" --until="now" 2>/dev/null | grep -E "Accepted|Failed|Disconnected" | sort -u || true) + if [ -z "$ssh_logs" ]; then + ssh_logs=$(journalctl -u sshd --since="@${last_check}" --until="now" 2>/dev/null | grep -E "Accepted|Failed|Disconnected" | sort -u || true) + fi + if [ -z "$ssh_logs" ]; then + ssh_logs=$(journalctl -u ssh --since="@${last_check}" --until="now" 2>/dev/null | grep -E "Accepted|Failed|Disconnected" | sort -u || true) + fi + else + ssh_logs=$(grep -E "sshd.*(Accepted|Failed|Disconnected)" /var/log/auth.log 2>/dev/null | sort -u || true) + fi + + declare -A SSH_ACCEPT_NOTIFY_KEYS + if [ -n "$ssh_logs" ]; then + while IFS= read -r line; do + [[ -z "$line" ]] && continue + if echo "$line" | grep -q "Accepted"; then + local user ip + user=$(sed -nE 's/.* for ([^ ]+) from .*/\1/p' <<<"$line") + ip="$(extract_ssh_client_ip "$line")" + if [ -n "$user" ]; then + local dedupe_port dedupe_key + dedupe_port=$(sed -nE 's/.* from [^ ]+ port ([0-9]+).*/\1/p' <<<"$line") + dedupe_key="${user}|${ip:-}|${dedupe_port:-}" + if [[ -n "${SSH_ACCEPT_NOTIFY_KEYS[$dedupe_key]:-}" ]]; then + write_log "SSH Accepted: дубль в журнале (user=${user} ip=${ip:-} port=${dedupe_port:-?}), второе уведомление не отправляем" + continue + fi + local dedupe_net_key last_nf now_ded delta + dedupe_net_key="${user}|${ip:-}" + if [[ "${SSH_ACCEPT_NOTIFY_DEDUP_SEC:-5}" =~ ^[0-9]+$ ]] && [ "${SSH_ACCEPT_NOTIFY_DEDUP_SEC:-5}" -gt 0 ]; then + now_ded=$(date '+%s') + last_nf="${SSH_ACCEPT_LAST_NOTIFY[$dedupe_net_key]:-0}" + if [ "${last_nf:-0}" -gt 0 ]; then + delta=$((now_ded - last_nf)) + if [ "$delta" -ge 0 ] && [ "$delta" -lt "$SSH_ACCEPT_NOTIFY_DEDUP_SEC" ]; then + write_log "SSH Accepted: повтор для user=${user} ip=${ip:-} через ${delta}s (< ${SSH_ACCEPT_NOTIFY_DEDUP_SEC}s), второе Telegram не отправляем" + continue + fi + fi + fi + SSH_ACCEPT_NOTIFY_KEYS[$dedupe_key]=1 + SSH_ACCEPT_LAST_NOTIFY[$dedupe_net_key]=$(date '+%s') + local message=" ✅ УСПЕШНОЕ SSH ПОДКЛЮЧЕНИЕ "$'\n' + message="${message}👤 Пользователь: ${user}"$'\n' + message="${message}🌐 IP адрес: ${ip:-local}"$'\n' + message="${message}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')" + SAC_D_USER="$user" SAC_D_SOURCE_IP="${ip:-}" SAC_D_IP="${ip:-}" SAC_D_PORT="${dedupe_port:-}" \ + notify_or_sac "ssh.login.success" "info" "Успешное SSH" "${user} с ${ip:-local}" "$message" + write_log "SSH успешный вход: $user с IP ${ip:-local}" + if [ "$user" = "root" ]; then + local rmsg=" 🔑 SSH ВХОД ПОД ROOT "$'\n' + rmsg="${rmsg}🌐 IP: ${ip:-local}"$'\n' + rmsg="${rmsg}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')" + SAC_D_USER="root" SAC_D_SOURCE_IP="${ip:-}" SAC_D_IP="${ip:-}" SAC_D_PORT="${dedupe_port:-}" \ + notify_or_sac "ssh.login.success" "warning" "SSH вход под root" "root с ${ip:-local}" "$rmsg" + fi + fi + elif echo "$line" | grep -q "Failed password"; then + local user ip + user=$(sed -nE 's/.* for (invalid user )?([^ ]+) from .*/\2/p' <<<"$line") + ip="$(extract_ssh_client_ip "$line")" + + if [ -n "$ip" ]; then + local current_attempts=1 + if [ -n "${FAIL_COUNTS[$ip]:-}" ]; then + current_attempts=${FAIL_COUNTS[$ip]} + current_attempts=$((current_attempts + 1)) + fi + FAIL_COUNTS[$ip]=$current_attempts + + if is_ip_blocked "$ip"; then + write_log "Попытка входа с заблокированного IP $ip (пользователь: $user)" + else + if [ "$current_attempts" -ge "$MAX_ATTEMPTS" ]; then + block_ip "$ip" "$current_attempts" + unset "FAIL_COUNTS[$ip]" + else + if [ "$current_attempts" -ge 2 ]; then + local message=" ❌ НЕУДАЧНАЯ ПОПЫТКА SSH "$'\n' + message="${message}👤 Пользователь: ${user}"$'\n' + message="${message}🌐 IP адрес: ${ip}"$'\n' + message="${message}📊 Попытка ${current_attempts} из ${MAX_ATTEMPTS}"$'\n' + message="${message}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')" + SAC_D_USER="$user" SAC_D_IP="$ip" SAC_D_ATTEMPT="$current_attempts" SAC_D_MAX="$MAX_ATTEMPTS" \ + notify_or_sac "ssh.login.failed" "warning" "Неудачная SSH" "${user} с ${ip} (${current_attempts}/${MAX_ATTEMPTS})" "$message" + fi + write_log "SSH неудачная попытка: $user с IP $ip (попытка $current_attempts из $MAX_ATTEMPTS)" + fi + fi + fi + fi + done <<<"$ssh_logs" + fi + + write_state_file "$LAST_SSH_CHECK_FILE" "$current_time" +} + +# ============================================ +# Мониторинг sudo +# ============================================ + +# Строка успешного sudo с перечислением окружения (TTY, PWD, USER, COMMAND). +# В journal идентификатор часто НЕ равен "sudo", поэтому не используем только journalctl -t sudo. +is_sudo_command_audit_line() { + [[ "$1" == *COMMAND=* ]] || return 1 + grep -Eq 'sudo(\[[0-9]+\])?:[[:space:]].*COMMAND=' <<<"$1" 2>/dev/null || \ + grep -Eq 'sudo:[[:space:]].*COMMAND=' <<<"$1" 2>/dev/null +} + +parse_sudo_audit_line() { + local line="$1" + local -n out_user="$2" + local -n out_cmd="$3" + local -n out_pwd="$4" + local -n out_runuser="$5" + + out_user="" + out_cmd="" + out_pwd="" + out_runuser="" + + if [[ "$line" =~ sudo\[[0-9]+\]:[[:space:]]([^[:space:]]+)[[:space:]]+:[[:space:]] ]]; then + out_user="${BASH_REMATCH[1]}" + elif [[ "$line" =~ sudo:[[:space:]]([^[:space:]]+)[[:space:]]+:[[:space:]] ]]; then + out_user="${BASH_REMATCH[1]}" + fi + + if [[ "$line" =~ USER=([^[:space:];]+) ]]; then + out_runuser="${BASH_REMATCH[1]}" + fi + + if [[ "$line" =~ PWD=([^;]+) ]]; then + out_pwd="${BASH_REMATCH[1]}" + out_pwd="${out_pwd%"${out_pwd##*[![:space:]]}"}" + fi + + if [[ "$line" =~ COMMAND=(.*) ]]; then + out_cmd="${BASH_REMATCH[1]}" + out_cmd="${out_cmd%"${out_cmd##*[![:space:]]}"}" + fi +} + +monitor_sudo() { + local last_check="0" + if [ -f "$LAST_SUDO_CHECK_FILE" ]; then + last_check="$(<"$LAST_SUDO_CHECK_FILE")" + fi + if ! [[ "$last_check" =~ ^[0-9]+$ ]]; then + last_check=0 + fi + local current_time + current_time=$(date '+%s') + + local since_journal + if [ "$last_check" -eq 0 ]; then + since_journal="--since=-30m" + else + since_journal="--since=@${last_check}" + fi + + local sudo_logs="" + if command -v journalctl >/dev/null 2>&1; then + sudo_logs=$(journalctl $since_journal --until="now" --no-pager 2>/dev/null \ + | grep -E -- 'sudo(\[[0-9]+\])?:[[:space:]].*COMMAND=' || true) + elif [ -f /var/log/auth.log ]; then + sudo_logs=$(grep -E -- 'sudo(\[[0-9]+\])?:[[:space:]].*COMMAND=' /var/log/auth.log 2>/dev/null | tail -n 400 || true) + elif [ -f /var/log/secure ]; then + sudo_logs=$(grep -E -- 'sudo(\[[0-9]+\])?:[[:space:]].*COMMAND=' /var/log/secure 2>/dev/null | tail -n 400 || true) + else + sudo_logs="" + fi + + declare -A SUDO_DEDUP_BATCH + if [ -n "$sudo_logs" ]; then + while IFS= read -r line; do + [[ -z "$line" ]] && continue + is_sudo_command_audit_line "$line" || continue + + local fp="" + if command -v sha256sum >/dev/null 2>&1; then + fp=$(printf '%s' "$line" | sha256sum | awk '{print $1}') + else + fp=$(printf '%s' "$line" | cksum | awk '{print $1}') + fi + [[ -n "$fp" && -n "${SUDO_DEDUP_BATCH[$fp]:-}" ]] && continue + [[ -n "$fp" ]] && SUDO_DEDUP_BATCH[$fp]=1 + + local user sudo_cmd pwd run_as sudo_tty="" + parse_sudo_audit_line "$line" user sudo_cmd pwd run_as + if [[ "$line" =~ TTY=([^[:space:];]+) ]]; then + sudo_tty="${BASH_REMATCH[1]}" + fi + + if [ -n "$user" ] && [ -n "${sudo_cmd:-}" ]; then + if ssh_monitor_sudo_is_sac_maintenance "$sudo_cmd" 2>/dev/null; then + write_log "SUDO: пропуск SAC/update maintenance ($user): ${sudo_cmd:0:80}…" + continue + fi + local message=" ⚠️ ИСПОЛЬЗОВАНИЕ SUDO "$'\n' + message="${message}👤 Пользователь: ${user}"$'\n' + if [ -n "$run_as" ]; then + message="${message}🔑 От имени USER: ${run_as}"$'\n' + fi + message="${message}💻 Команда: ${sudo_cmd}"$'\n' + message="${message}📁 Директория: ${pwd:-unknown}"$'\n' + message="${message}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')" + local sac_summary="${user}: ${sudo_cmd}" + SAC_D_USER="$user" SAC_D_COMMAND="$sudo_cmd" SAC_D_RUN_AS="${run_as:-}" SAC_D_PWD="${pwd:-}" \ + SAC_D_TTY="${sudo_tty:-}" \ + notify_or_sac "privilege.sudo.command" "warning" "Использование sudo" "$sac_summary" "$message" + write_log "SUDO: $user выполнил $sudo_cmd${run_as:+ (USER=$run_as)}" + fi + done <<<"$sudo_logs" + fi + + write_state_file "$LAST_SUDO_CHECK_FILE" "$current_time" +} + +# ============================================ +# Ключевые события безопасности + брутфорс +# ============================================ + +monitor_security_events() { + local last_check="0" + if [ -f "$LAST_SECURITY_EVENTS_FILE" ]; then + last_check="$(<"$LAST_SECURITY_EVENTS_FILE")" + fi + if ! [[ "$last_check" =~ ^[0-9]+$ ]]; then + last_check=0 + fi + if [ "$last_check" -eq 0 ]; then + last_check=$(($(date '+%s') - 3600)) + fi + local now + now=$(date '+%s') + + local chunk="" + if command -v journalctl >/dev/null 2>&1; then + chunk=$(journalctl --since="@${last_check}" --until="now" 2>/dev/null | grep -Ei \ + "REMOTE HOST IDENTIFICATION HAS CHANGED|POSSIBLE BREAK-IN ATTEMPT|reverse mapping checking getaddrinfo|Failed password" \ + || true) + fi + + if [ -n "$chunk" ]; then + if echo "$chunk" | grep -Eqi "REMOTE HOST IDENTIFICATION HAS CHANGED"; then + local hk + hk="$(echo "$chunk" | grep -Ei "REMOTE HOST IDENTIFICATION HAS CHANGED" | head -n 3)" + local m=" 🔐 ВНИМАНИЕ: изменение SSH host key / MITM? "$'\n' + m="${m}$(printf '%s\n' "$hk")" + m="${m}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')" + notify_or_sac "agent.notification" "high" "SSH host key changed" "MITM?" "$m" + write_log "SECURITY: обнаружено сообщение об изменении host key" + fi + fi + + if command -v journalctl >/dev/null 2>&1; then + local brute_lines brute_ip count nowc lastn + brute_lines=$(journalctl -u ssh -u sshd --since="${BRUTE_WINDOW_SEC} seconds ago" 2>/dev/null | grep "Failed password" || true) + if [ -n "$brute_lines" ]; then + while IFS= read -r ip; do + [ -z "$ip" ] && continue + count=$(printf '%s\n' "$brute_lines" | grep -F "from ${ip} port" | wc -l | tr -d ' ') + if [ "${count:-0}" -ge "$BRUTE_MIN_FAILS" ]; then + nowc=$(date '+%s') + lastn="${BRUTE_LAST_NOTIFY[$ip]:-0}" + if [ $((nowc - lastn)) -ge "$BRUTE_NOTIFY_COOLDOWN_SEC" ]; then + BRUTE_LAST_NOTIFY[$ip]=$nowc + local bm=" 🧨 ВОЗМОЖНЫЙ МАССОВЫЙ БРУТФОРС SSH "$'\n' + bm="${bm}🌐 IP: ${ip}"$'\n' + bm="${bm}📊 Неудачных попыток за ~${BRUTE_WINDOW_SEC}s: ${count}"$'\n' + bm="${bm}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')" + SAC_D_IP="$ip" SAC_D_ATTEMPT="$count" \ + notify_or_sac "ssh.bruteforce.mass" "high" "Массовый брутфорс" "${ip}: ${count} за ${BRUTE_WINDOW_SEC}s" "$bm" + write_log "SECURITY: массовый брутфорс? IP=$ip fails=${count} за окно ${BRUTE_WINDOW_SEC}s" + fi + fi + done < <(printf '%s\n' "$brute_lines" | sed -nE 's/.* from ([^ ]+) port .*/\1/p' | sort -u) + fi + fi + + write_state_file "$LAST_SECURITY_EVENTS_FILE" "$now" +} + +# ============================================ +# Локальные сессии (systemd-logind, journalctl) +# ============================================ + +enrich_sac_logind_session_details() { + local sid="$1" + [ -n "$sid" ] || return 0 + SAC_D_SESSION_ID="$sid" + command -v loginctl >/dev/null 2>&1 || return 0 + local tty remote since + tty=$(loginctl show-session "$sid" -p TTY --value 2>/dev/null || true) + remote=$(loginctl show-session "$sid" -p RemoteHost --value 2>/dev/null || true) + since=$(loginctl show-session "$sid" -p Timestamp --value 2>/dev/null || true) + if [ -n "$tty" ] && [ "$tty" != "n/a" ]; then + SAC_D_TTY="$tty" + case "$tty" in pts/*) SAC_D_PTS="${tty#pts/}" ;; esac + fi + if [ -n "$remote" ] && [ "$remote" != "n/a" ]; then + SAC_D_SOURCE_IP="$remote" + fi + if [ -n "$since" ]; then + SAC_D_SINCE="$since" + fi +} + +# Возврат 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')" + enrich_sac_logind_session_details "$sid" + SAC_D_USER="${user:-}" \ + notify_or_sac "session.logind.new" "info" "Новая сессия logind" "${user:-?} sid=${sid:-?}" "$msg" + write_log "LOGIND: новая сессия: ${line}" + else + write_log "LOGIND: новая сессия (разбор user/sid не удался): ${line}" + fi + fi + + elif [[ "$low" == *"removed session"* ]]; then + write_log "LOGIND: сессия завершена: $line" + if [ "${LOGIND_NOTIFY_REMOVED:-0}" = "1" ]; then + local sess_r="" + if [[ "$line" =~ [Rr]emoved\ session[[:space:]]+([^[:space:].]+) ]]; then + sess_r="${BASH_REMATCH[1]}" + fi + local msg_r=" 🚪 СЕССИЯ ЗАКРЫТА (systemd-logind) "$'\n' + msg_r="${msg_r}🆔 Сессия: ${sess_r:-?}"$'\n' + msg_r="${msg_r}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')" + notify_or_sac "session.logind.removed" "info" "Сессия закрыта" "sid=${sess_r:-?}" "$msg_r" + fi + + elif [[ "$low" == *"failed"* ]]; then + if [ "${LOGIND_NOTIFY_FAILED:-1}" = "1" ]; then + local msg_f=" ❌ СБОЙ (systemd-logind) "$'\n' + msg_f="${msg_f}📝 ${line}"$'\n' + msg_f="${msg_f}🕐 Время: $(notification_date '+%d.%m.%Y %H:%M:%S')" + notify_or_sac "session.logind.failed" "warning" "Сбой logind" "logind failed" "$msg_f" + write_log "LOGIND: сбой: $line" + fi + fi + done <<<"$logind_lines" + fi + + write_state_file "$LAST_LOGIND_CHECK_FILE" "$current_time" +} + +# ============================================ +# Ежедневный отчёт +# ============================================ + +journal_count_since_24h() { + local pattern="$1" + local c + if command -v journalctl >/dev/null 2>&1; then + c=$(journalctl -u ssh -u sshd --since="24 hours ago" 2>/dev/null | grep -F -c "$pattern" || true) + echo "${c:-0}" + else + echo "0" + fi +} + +journal_sudo_count_24h() { + local c + if command -v journalctl >/dev/null 2>&1; then + c=$(journalctl --since="24 hours ago" --no-pager 2>/dev/null \ + | grep -E -- 'sudo(\[[0-9]+\])?:[[:space:]].*COMMAND=' | wc -l | tr -d ' ' || true) + echo "${c:-0}" + else + echo "0" + fi +} + +top_failed_ips_journal_24h() { + local n="$1" + journalctl -u ssh -u sshd --since="24 hours ago" 2>/dev/null \ + | grep "Failed password" \ + | sed -nE 's/.* from ([^ ]+) port .*/\1/p' \ + | sort | uniq -c | sort -nr | head -n "$n" \ + | awk '{gsub(/^ +/,"",$1); print $2 ":" $1}' || true +} + +send_daily_report() { + local current_time + current_time=$(report_date '+%d.%m.%Y %H:%M:%S') + + local successful_logins failed_logins sudo_commands + local top_lines="" + local json_stats="" + + if command -v journalctl >/dev/null 2>&1; then + successful_logins=$(journal_count_since_24h "Accepted") + failed_logins=$(journal_count_since_24h "Failed password") + sudo_commands=$(journal_sudo_count_24h) + top_lines=$(top_failed_ips_journal_24h "${DAILY_REPORT_TOP_IPS:-5}" | sed 's/^/ • /; s/:/ — /' || true) + else + json_stats="$(stats_auth_log_24h_json "${DAILY_REPORT_TOP_IPS:-5}")" + successful_logins=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['accepted'])" "$json_stats" 2>/dev/null || echo "0") + failed_logins=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['failed'])" "$json_stats" 2>/dev/null || echo "0") + sudo_commands=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['sudo'])" "$json_stats" 2>/dev/null || echo "0") + top_lines=$(printf '%s' "$json_stats" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(chr(10).join(' • '+t.replace(':',' — ',1) for t in d.get('top',[])))" 2>/dev/null || true) + fi + + local active_sessions + active_sessions=$(get_active_ssh_sessions) + local active_count + active_count=$(printf '%s' "$active_sessions" | grep -c '👤' || true) + active_count=${active_count:-0} + + local active_bans=0 + local ban_line + if [ "${ENABLE_IP_BAN:-1}" = "1" ]; then + if command -v ipset >/dev/null 2>&1; then + active_bans=$(ipset_active_ban_count) + else + local current_epoch + current_epoch=$(date '+%s') + if [ -f "$BAN_LIST_FILE" ]; then + while IFS='|' read -r ip ban_until attempts ban_time; do + if [ -n "$ip" ] && [ -n "$ban_until" ] && [ "$current_epoch" -lt "$ban_until" ]; then + active_bans=$((active_bans + 1)) + fi + done <"$BAN_LIST_FILE" + fi + fi + ban_line="🚫 Активных банов (ipset): ${active_bans}" + else + ban_line="🚫 Автобан IP: отключён (ENABLE_IP_BAN=0)" + fi + + local message="" + message+="📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH МОНИТОРИНГА"$'\n' + message+="Agent version ${SSH_MONITOR_VERSION:-unknown}"$'\n' + message+="🖥️ Сервер: $(server_display_name_with_ip)"$'\n' + message+="🕐 Время отчета: ${current_time}"$'\n' + message+=""$'\n' + message+="📈 СТАТИСТИКА ЗА ПОСЛЕДНИЕ 24 ЧАСА:"$'\n' + message+=" ✅ Успешных SSH подключений: ${successful_logins:-0}"$'\n' + message+=" ❌ Неудачных попыток SSH: ${failed_logins:-0}"$'\n' + message+=" ⚠️ Команд через sudo: ${sudo_commands:-0}"$'\n' + message+=" ${ban_line}"$'\n' + message+=""$'\n' + message+="🧾 ТОП-${DAILY_REPORT_TOP_IPS:-5} IP ПО НЕУДАЧНЫМ ПОПЫТКАМ:"$'\n' + if [ -n "$top_lines" ]; then + message+="${top_lines}"$'\n' + else + message+=" (нет данных)"$'\n' + fi + message+=""$'\n' + message+="👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (${active_count}):"$'\n' + + if [ -n "$active_sessions" ]; then + message+="${active_sessions}"$'\n' + else + message+=" (нет данных)"$'\n' + fi + + message="$(append_agent_notification_source "$message")" + + local active_users_json + active_users_json="$(printf '%s' "$active_sessions" | python3 -c 'import json,sys; print(json.dumps([l.strip() for l in sys.stdin.read().splitlines() if l.strip()]))' 2>/dev/null || echo '[]')" + + local sac_summary sac_details_json + sac_summary="SSH 24ч: успех ${successful_logins:-0}, неудач ${failed_logins:-0}, sudo ${sudo_commands:-0}" + sac_details_json="$( + SAC_REPORT_BODY="$message" \ + SAC_OK="${successful_logins:-0}" \ + SAC_FAIL="${failed_logins:-0}" \ + SAC_SUDO="${sudo_commands:-0}" \ + SAC_BANS="${active_bans:-0}" \ + SAC_ACTIVE="${active_count:-0}" \ + SAC_ACTIVE_USERS="$active_users_json" \ + python3 <<'PY' +import html +import json +import os + +body = os.environ.get("SAC_REPORT_BODY", "") +active_users = json.loads(os.environ.get("SAC_ACTIVE_USERS", "[]") or "[]") +ok = int(os.environ.get("SAC_OK", "0") or 0) +fail = int(os.environ.get("SAC_FAIL", "0") or 0) +stats = { + "platform": "ssh", + "successful_logins": ok, + "failed_logins": fail, + "successful_ssh": ok, + "failed_ssh": fail, + "sudo_commands": int(os.environ.get("SAC_SUDO", "0") or 0), + "active_bans": int(os.environ.get("SAC_BANS", "0") or 0), + "active_sessions": int(os.environ.get("SAC_ACTIVE", "0") or 0), + "active_users": active_users, + "generated_by": "agent", +} +escaped = html.escape(body) +report_html = '
' + escaped.replace("\n", "
") + "
" +print( + json.dumps( + { + "stats": stats, + "report_body": body, + "report_format": "plain", + "report_html": report_html, + }, + ensure_ascii=False, + ) +) +PY +)" || sac_details_json="" + + notify_or_sac "report.daily.ssh" "info" "Ежедневный отчёт SSH" "$sac_summary" "$message" "$sac_details_json" + write_log "Ежедневный отчет отправлен" + + write_state_file "$LAST_REPORT_FILE" "$(report_date '+%F')" +} + +read_last_report_date() { + if [ ! -f "$LAST_REPORT_FILE" ]; then + echo "" + return + fi + local raw + raw="$(tr -d ' \t\r\n' <"$LAST_REPORT_FILE" || true)" + if [[ "$raw" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then + echo "$raw" + return + fi + if [[ "$raw" =~ ^[0-9]+$ ]]; then + report_date -d "@$raw" '+%F' 2>/dev/null || echo "" + return + fi + echo "" +} + +check_daily_report() { + if [ "${DAILY_REPORT_ENABLED:-1}" != "1" ]; then + return 0 + fi + local today + today=$(report_date '+%F') + local last_date + last_date="$(read_last_report_date)" + + if [ "$last_date" = "$today" ]; then + return 0 + fi + + local cur_h cur_m cur_mins rep_mins + cur_h=$(report_date '+%-H') + cur_m=$(report_date '+%-M') + cur_mins=$((10#$cur_h * 60 + 10#$cur_m)) + rep_mins=$((10#$DAILY_REPORT_HOUR * 60 + 0)) + + if [ "$cur_mins" -ge "$rep_mins" ]; then + send_daily_report + fi +} + +# ============================================ +# Heartbeat +# ============================================ + +send_heartbeat() { + local timestamp + timestamp=$(date '+%s') + local timestamp_human + timestamp_human=$(notification_date '+%d.%m.%Y %H:%M:%S') + + local message=" ❤️ Heartbeat - скрипт мониторинга работает "$'\n' + message="${message}🕐 Время: ${timestamp_human}" + + notify_or_sac "agent.heartbeat" "info" "Heartbeat" "ssh-monitor alive" "$message" + write_state_file "$LAST_HEARTBEAT_FILE" "$timestamp" + write_log "Heartbeat отправлен" +} + +check_heartbeat() { + local last_heartbeat_epoch=0 + + if [ -f "$LAST_HEARTBEAT_FILE" ]; then + last_heartbeat_epoch=$(cat "$LAST_HEARTBEAT_FILE" 2>/dev/null || echo "0") + fi + + local current_epoch + current_epoch=$(date '+%s') + local diff=$((current_epoch - last_heartbeat_epoch)) + local heartbeat_interval_sec=$((HEARTBEAT_INTERVAL_MINUTES * 60)) + + if [ "$last_heartbeat_epoch" -eq 0 ] || [ "$diff" -ge "$heartbeat_interval_sec" ]; then + send_heartbeat + fi +} + +# ============================================ +# Эксплуатация: health + Prometheus textfile +# ============================================ + +write_health_and_metrics() { + local ts + ts=$(date '+%s') + if [ -n "${HEALTHCHECK_STATUS_FILE:-}" ]; then + ssh_monitor_secure_dir "$(dirname "$HEALTHCHECK_STATUS_FILE")" 2>/dev/null || mkdir -p "$(dirname "$HEALTHCHECK_STATUS_FILE")" 2>/dev/null || true + if command -v python3 >/dev/null 2>&1; then + python3 -c 'import json,sys; print(json.dumps({"ts": int(sys.argv[1]), "hostname": sys.argv[2], "dry_run": sys.argv[3] == "1"}))' \ + "$ts" "$(hostname)" "${DRY_RUN:-0}" >"${HEALTHCHECK_STATUS_FILE}.tmp" + ssh_monitor_secure_file "${HEALTHCHECK_STATUS_FILE}.tmp" 2>/dev/null || true + mv "${HEALTHCHECK_STATUS_FILE}.tmp" "$HEALTHCHECK_STATUS_FILE" 2>/dev/null || true + ssh_monitor_secure_file "$HEALTHCHECK_STATUS_FILE" 2>/dev/null || true + else + write_log "WARN: python3 недоступен — HEALTHCHECK_STATUS_FILE пропущен" + fi + fi + if [ -n "${PROMETHEUS_TEXTFILE_DIR:-}" ]; then + mkdir -p "$PROMETHEUS_TEXTFILE_DIR" 2>/dev/null || true + local f="$PROMETHEUS_TEXTFILE_DIR/ssh_monitor.prom" + { + echo "# HELP ssh_monitor_last_loop_unixtime Unix time последней итерации цикла ssh-monitor" + echo "# TYPE ssh_monitor_last_loop_unixtime gauge" + printf 'ssh_monitor_last_loop_unixtime %s\n' "$ts" + } >"${f}.tmp" + mv "${f}.tmp" "$f" 2>/dev/null || true + fi +} + +# ============================================ +# Старт / стоп +# ============================================ + +send_startup_notification() { + if ssh_monitor_update_in_progress 2>/dev/null; then + write_log "lifecycle startup: пропуск (идёт agent-update)" + return 0 + fi + local timestamp + timestamp=$(notification_date '+%d.%m.%Y %H:%M:%S') + local whitelist_count=$((${#WHITELIST_IPS[@]} + ${#WHITELIST_SUBNETS[@]})) + + local message=" ✅ СКРИПТ МОНИТОРИНГА ЗАПУЩЕН "$'\n' + message="${message}📌 Версия: ${SSH_MONITOR_VERSION:-?}"$'\n' + message="${message}🕐 Время запуска: ${timestamp}"$'\n' + message="${message}📊 Мониторинг SSH, SUDO и systemd-logind активирован"$'\n' + if [ "${ENABLE_IP_BAN:-1}" = "1" ]; then + message="${message}🚫 Автоблокировка (ipset): после ${MAX_ATTEMPTS} попыток, timeout ${BAN_TIME} с ($IPSET_NAME_V4)"$'\n' + else + message="${message}🚫 Автоблокировка IP: выключена (ENABLE_IP_BAN=0), только уведомления"$'\n' + fi + message="${message}⚪ Белый список: ${whitelist_count} IP/подсетей"$'\n' + message="${message}❤️ Heartbeat будет отправляться каждые ${HEARTBEAT_INTERVAL_MINUTES} мин (SAC agent.heartbeat)"$'\n' + message="${message}📢 Каналы уведомлений (все по списку): $(notify_chain_human)" + message="$(message_ensure_server_line "$message")" + + local lifecycle_details + lifecycle_details="$(NOTIFICATION_BODY="$message" python3 -c 'import json, os; print(json.dumps({"lifecycle":"started","trigger":"boot","notification_body":os.environ["NOTIFICATION_BODY"]}, ensure_ascii=False))')" + notify_or_sac "agent.lifecycle" "info" "Мониторинг запущен" "ssh-monitor started" "$message" "$lifecycle_details" + write_log "Скрипт мониторинга запущен (белый список: $whitelist_count записей)" + send_heartbeat +} + +send_shutdown_notification() { + [ "${SHUTDOWN_NOTIFIED:-0}" = "1" ] && return 0 + if ssh_monitor_update_in_progress 2>/dev/null; then + write_log "lifecycle shutdown: пропуск (идёт agent-update)" + return 0 + fi + SHUTDOWN_NOTIFIED=1 + SAC_SUPPRESS_FAIL_COUNT=1 + + local timestamp + timestamp=$(notification_date '+%d.%m.%Y %H:%M:%S') + local message=" ⚠️ СКРИПТ МОНИТОРИНГА ОСТАНОВЛЕН "$'\n' + message="${message}🕐 Время остановки: ${timestamp}" + message="$(message_ensure_server_line "$message")" + + local lifecycle_details + lifecycle_details="$(NOTIFICATION_BODY="$message" python3 -c 'import json, os; print(json.dumps({"lifecycle":"stopped","trigger":"shutdown","notification_body":os.environ["NOTIFICATION_BODY"]}, ensure_ascii=False))')" + notify_or_sac "agent.lifecycle" "info" "Мониторинг остановлен" "ssh-monitor stopped" "$message" "$lifecycle_details" + write_log "Скрипт мониторинга остановлен" +} + +main() { + if [ "$EUID" -ne 0 ] && [ "${DRY_RUN:-0}" != "1" ]; then + echo "Пожалуйста, запустите скрипт от имени root (sudo $0)" + exit 1 + fi + + load_whitelist_from_file + if [ "${ENABLE_IP_BAN:-1}" = "1" ]; then + ensure_ipset_and_iptables || write_log "WARN: ensure_ipset_and_iptables завершился с ошибкой" + load_existing_bans + else + write_log "ENABLE_IP_BAN=0: мониторинг и уведомления без ipset/автобана" + fi + send_startup_notification + + local last_ban_check + last_ban_check=$(date '+%s') + + while true; do + monitor_ssh + monitor_sudo + monitor_security_events + monitor_logind + check_daily_report + check_heartbeat + sac_flush_spool "${SAC_SPOOL_FLUSH_MAX_FILES:-50}" 2>/dev/null || true + write_health_and_metrics + + local current_time + current_time=$(date '+%s') + if [ "${ENABLE_IP_BAN:-1}" = "1" ] && [ $((current_time - last_ban_check)) -ge "$BAN_CHECK_INTERVAL" ]; then + unblock_expired_ips + verify_ban_list_vs_ipset + last_ban_check=$current_time + fi + + sleep "$MONITOR_INTERVAL" + done +} + +run_check_config() { + printf 'Проверка конфигурации ssh-monitor\n' + printf 'SSH_MONITOR_VERSION=%s\n' "${SSH_MONITOR_VERSION:-}" + printf 'CONFIG_FILE=%s\n' "$CONFIG_FILE" + printf 'NOTIFY_ORDER=%s (пусто = авто: только настроенные; при событии — отправка во все каналы списка)\n' "${NOTIFY_ORDER:-}" + printf 'NOTIFY_CHAIN (эффективная очередь)=%s\n' "$(notify_chain_human)" + printf 'TELEGRAM: %s\n' "$([ -n "$TELEGRAM_BOT_TOKEN" ] && echo настроен || echo НЕ настроен)" + printf 'LOG_FILE=%s\n' "$LOG_FILE" + printf 'DAILY_REPORT_HOUR=%s\n' "$DAILY_REPORT_HOUR" + printf 'DAILY_REPORT_TZ=%s (пусто = зона процесса/date)\n' "${DAILY_REPORT_TZ:-}" + printf 'NOTIFY_TZ=%s (пусто = DAILY_REPORT_TZ или зона процесса; см. README)\n' "${NOTIFY_TZ:-}" + printf 'SERVER_DISPLAY_NAME=%s (пусто = hostname; эффективно: %s)\n' "${SERVER_DISPLAY_NAME:-}" "$(server_display_name_with_ip)" + printf 'SSH_ACCEPT_NOTIFY_DEDUP_SEC=%s\n' "${SSH_ACCEPT_NOTIFY_DEDUP_SEC:-5}" + printf 'DAILY_REPORT_TOP_IPS=%s\n' "$DAILY_REPORT_TOP_IPS" + printf 'BRUTE_WINDOW_SEC=%s BRUTE_MIN_FAILS=%s\n' "$BRUTE_WINDOW_SEC" "$BRUTE_MIN_FAILS" + printf 'ENABLE_IP_BAN=%s (1=мониторинг+бан ipset, 0=только мониторинг и уведомления)\n' "${ENABLE_IP_BAN:-1}" + printf 'IPSET_NAME_V4=%s IPSET_NAME_V6=%s IPSET_MAXELEM=%s\n' "$IPSET_NAME_V4" "$IPSET_NAME_V6" "$IPSET_MAXELEM" + printf 'PROMETHEUS_TEXTFILE_DIR=%s\n' "${PROMETHEUS_TEXTFILE_DIR:-}" + printf 'HEALTHCHECK_STATUS_FILE=%s\n' "${HEALTHCHECK_STATUS_FILE:-}" + printf 'ENABLE_LOGIND_MONITOR=%s LAST_LOGIND_CHECK_FILE=%s\n' "${ENABLE_LOGIND_MONITOR:-}" "${LAST_LOGIND_CHECK_FILE:-}" + printf 'LOGIND_NOTIFY_NEW=%s LOGIND_NOTIFY_REMOVED=%s LOGIND_NOTIFY_FAILED=%s LOGIND_SKIP_REMOTE=%s\n' \ + "${LOGIND_NOTIFY_NEW:-}" "${LOGIND_NOTIFY_REMOVED:-}" "${LOGIND_NOTIFY_FAILED:-}" "${LOGIND_SKIP_REMOTE:-}" + printf 'MAIL_SMTP: host=%s port=%s from=%s to=%s STARTTLS=%s SSL=%s\n' \ + "${MAIL_SMTP_HOST:-}" "${MAIL_SMTP_PORT:-}" "${MAIL_FROM:-}" "${MAIL_TO:-}" \ + "${MAIL_SMTP_STARTTLS:-}" "${MAIL_SMTP_SSL:-}" + printf 'MAIL_SMTP_USER: %s\n' "$([ -n "${MAIL_SMTP_USER:-}" ] && echo задан || echo не задан)" + printf 'MAIL_SMTP_PASSWORD: %s\n' "$([ -n "${MAIL_SMTP_PASSWORD:-}" ] && echo задан || echo не задан)" + printf 'UseSAC=%s SAC_URL=%s SAC_API_KEY=%s\n' "${UseSAC:-off}" "${SAC_URL:-}" \ + "$([ -n "${SAC_API_KEY:-}" ] && echo задан || echo не задан)" + printf 'CONFIG_STRICT_PERMS=%s SAC_TLS_INSECURE=%s\n' "${CONFIG_STRICT_PERMS:-0}" "${SAC_TLS_INSECURE:-0}" + ssh_monitor_audit_state_paths + if bash -n "$0" 2>/dev/null; then + printf 'bash -n: OK\n' + else + printf 'bash -n: ОШИБКА\n' >&2 + return 1 + fi + return 0 +} + +parse_args() { + for a in "$@"; do + case "$a" in + --check-config) CHECK_CONFIG_ONLY=1 ;; + --check-sac) CHECK_SAC_ONLY=1 ;; + --dry-run) DRY_RUN=1 ;; + esac + done +} + +# ============================================ +# ОБРАБОТЧИК ОСТАНОВКИ СКРИПТА +# ============================================ + +parse_args "$@" + +load_config +validate_config + +if [ "$CHECK_CONFIG_ONLY" = "1" ]; then + run_check_config + exit $? +fi + +if [ "${CHECK_SAC_ONLY:-0}" = "1" ]; then + run_check_sac + exit $? +fi + +trap send_shutdown_notification EXIT INT TERM + +mkdir -p "$(dirname "$LOG_FILE")" +ssh_monitor_secure_file "$LOG_FILE" 2>/dev/null || touch "$LOG_FILE" + +write_log "Конфигурация загружена из $CONFIG_FILE (или дефолты), версия ${SSH_MONITOR_VERSION:-?}, очередь оповещений: $(notify_chain_human)" +if [ "${ENABLE_IP_BAN:-1}" = "1" ]; then + write_log "Режим: мониторинг + автобан IP (ENABLE_IP_BAN=1)" +else + write_log "Режим: только мониторинг и уведомления (ENABLE_IP_BAN=0)" +fi + +if [ "${DRY_RUN:-0}" = "1" ]; then + if [ "${ENABLE_IP_BAN:-1}" = "1" ]; then + write_log "Режим DRY-RUN: ipset/iptables не меняются, уведомления печатаются в stderr" + else + write_log "Режим DRY-RUN: уведомления печатаются в stderr" + fi +fi + +main diff --git a/ssh-monitor-perms.sh b/ssh-monitor-perms.sh new file mode 100644 index 0000000..b963af2 --- /dev/null +++ b/ssh-monitor-perms.sh @@ -0,0 +1,434 @@ +#!/bin/bash +# Path permission helpers for ssh-monitor, sac-client, watchdog, updater. +# shellcheck shell=bash + +ssh_monitor_config_peek() { + local key="$1" file="$2" default="${3:-}" + local line val + [ -f "$file" ] || { + printf '%s\n' "$default" + return 0 + } + line="$(grep -E "^[[:space:]]*${key}=" "$file" 2>/dev/null | tail -1)" || { + printf '%s\n' "$default" + return 0 + } + val="${line#*=}" + val="${val#"${val%%[![:space:]]*}"}" + val="${val%"${val##*[![:space:]]}"}" + val="${val#\"}" + val="${val%\"}" + val="${val#\'}" + val="${val%\'}" + printf '%s\n' "$val" +} + +# --- Безопасная загрузка /etc/ssh-monitor.conf (2.3.0+: без source / выполнения bash) --- + +_ssh_monitor_config_trim() { + local v="$1" + v="${v#"${v%%[![:space:]]*}"}" + v="${v%"${v##*[![:space:]]}"}" + printf '%s' "$v" +} + +ssh_monitor_config_is_known_key() { + case "$1" in + TELEGRAM_BOT_TOKEN | TELEGRAM_CHAT_ID | NOTIFY_ORDER | \ + UseSAC | SAC_URL | SAC_API_KEY | SAC_SPOOL_DIR | SAC_FAIL_COUNT_FILE | \ + SAC_FALLBACK_FAILURES | SAC_TIMEOUT_SEC | SAC_SPOOL_FLUSH_MAX_FILES | \ + SAC_SPOOL_MAX_AGE_HOURS | SAC_TLS_INSECURE | SAC_SEND_HEARTBEAT | SAC_AGENT_ID_FILE | \ + CONFIG_STRICT_PERMS | \ + MAIL_SMTP_HOST | MAIL_SMTP_PORT | MAIL_SMTP_USER | MAIL_SMTP_PASSWORD | \ + MAIL_FROM | MAIL_TO | MAIL_SMTP_STARTTLS | MAIL_SMTP_SSL | \ + LOG_FILE | LAST_HEARTBEAT_FILE | HEARTBEAT_INTERVAL_MINUTES | LAST_REPORT_FILE | \ + LAST_SSH_CHECK_FILE | LAST_SUDO_CHECK_FILE | LAST_SECURITY_EVENTS_FILE | LAST_LOGIND_CHECK_FILE | \ + DAILY_REPORT_ENABLED | DAILY_REPORT_HOUR | DAILY_REPORT_TZ | DAILY_REPORT_TOP_IPS | \ + NOTIFY_TZ | SERVER_DISPLAY_NAME | SERVER_IPV4 | SSH_ACCEPT_NOTIFY_DEDUP_SEC | \ + ENABLE_LOGIND_MONITOR | LOGIND_NOTIFY_NEW | LOGIND_NOTIFY_REMOVED | LOGIND_NOTIFY_FAILED | LOGIND_SKIP_REMOTE | \ + PROMETHEUS_TEXTFILE_DIR | HEALTHCHECK_STATUS_FILE | \ + BRUTE_WINDOW_SEC | BRUTE_MIN_FAILS | BRUTE_NOTIFY_COOLDOWN_SEC | \ + ENABLE_IP_BAN | BAN_TIME | MAX_ATTEMPTS | BAN_CHECK_INTERVAL | MONITOR_INTERVAL | BAN_LIST_FILE | \ + IPSET_NAME_V4 | IPSET_NAME_V6 | IPSET_MAXELEM | \ + WHITELIST_IPS | WHITELIST_SUBNETS | \ + WATCHDOG_MAX_HEARTBEAT_AGE | WATCHDOG_LOG_FILE | WATCHDOG_SERVICE_NAME | WATCHDOG_NOTIFY_ON_RECOVERY | \ + GIT_REF | GIT_VERIFY_MODE | GIT_ALLOW_RESET | GIT_BRANCH | REPO_URL) + return 0 + ;; + BACKUP_WEBHOOK_URL) + return 2 + ;; + esac + return 1 +} + +# Разбор строки KEY=value. Устанавливает _CFG_KEY / _CFG_VAL. +# 0 ok | 1 пусто/комментарий | 2 битая строка | 3 подстановка shell в value +ssh_monitor_config_parse_line() { + local line="$1" raw_val + _CFG_KEY="" _CFG_VAL="" + line="$(_ssh_monitor_config_trim "$line")" + [ -n "$line" ] || return 1 + [[ "$line" == \#* ]] && return 1 + if [[ ! "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then + return 2 + fi + _CFG_KEY="${BASH_REMATCH[1]}" + raw_val="${BASH_REMATCH[2]}" + raw_val="$(_ssh_monitor_config_trim "$raw_val")" + if [ -z "$raw_val" ]; then + _CFG_VAL="" + return 0 + fi + case "$raw_val" in + \"*\") + _CFG_VAL="${raw_val#\"}" + _CFG_VAL="${_CFG_VAL%\"}" + ;; + \'*\') + _CFG_VAL="${raw_val#\'}" + _CFG_VAL="${_CFG_VAL%\'}" + ;; + *) + _CFG_VAL="$raw_val" + ;; + esac + if [[ "$_CFG_VAL" == *'$('* || "$_CFG_VAL" == *'`'* ]]; then + return 3 + fi + return 0 +} + +ssh_monitor_config_load_file() { + local file="$1" + local line rc line_no=0 + [ -f "$file" ] || return 1 + while IFS= read -r line || [ -n "$line" ]; do + line_no=$((line_no + 1)) + ssh_monitor_config_parse_line "$line" + rc=$? + if [ "$rc" -eq 1 ]; then + continue + fi + if [ "$rc" -eq 2 ]; then + printf 'WARN: конфиг %s:%s: пропуск нераспознанной строки: %s\n' "$file" "$line_no" "$line" >&2 + continue + fi + if [ "$rc" -eq 3 ]; then + printf 'ERROR: конфиг %s:%s: недопустимая подстановка shell в %s=\n' "$file" "$line_no" "$_CFG_KEY" >&2 + return 1 + fi + ssh_monitor_config_is_known_key "$_CFG_KEY" + rc=$? + if [ "$rc" -eq 2 ]; then + printf 'WARN: конфиг %s:%s: BACKUP_WEBHOOK_URL удалён в 2.3.0-SAC — игнорируется\n' "$file" "$line_no" >&2 + continue + fi + if [ "$rc" -eq 1 ]; then + printf 'WARN: конфиг %s:%s: неизвестный ключ %s (игнорируется)\n' "$file" "$line_no" "$_CFG_KEY" >&2 + continue + fi + printf -v "$_CFG_KEY" '%s' "$_CFG_VAL" + done <"$file" + return 0 +} + +ssh_monitor_validate_ipv4() { + local ip="$1" o + ip="$(_ssh_monitor_config_trim "$ip")" + [[ "$ip" =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$ ]] || return 1 + for o in "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}" "${BASH_REMATCH[4]}"; do + ((10#$o <= 255)) || return 1 + done + return 0 +} + +ssh_monitor_validate_cidr_v4() { + local cidr="$1" net mask + cidr="$(_ssh_monitor_config_trim "$cidr")" + [[ "$cidr" == */* ]] || return 1 + net="${cidr%/*}" + mask="${cidr#*/}" + [[ "$mask" =~ ^[0-9]+$ ]] && [ "$mask" -ge 0 ] && [ "$mask" -le 32 ] || return 1 + ssh_monitor_validate_ipv4 "$net" +} + +ssh_monitor_validate_http_url() { + local name="$1" value="$2" optional="${3:-0}" + value="$(_ssh_monitor_config_trim "$value")" + if [ -z "$value" ]; then + [ "$optional" = "1" ] && return 0 + printf 'ERROR: %s: пустой URL\n' "$name" >&2 + return 1 + fi + if [[ ! "$value" =~ ^https?://[^/[:space:]]+ ]]; then + printf 'ERROR: %s: ожидается http(s)://host…, получено %s\n' "$name" "$value" >&2 + return 1 + fi + return 0 +} + +ssh_monitor_validate_hostname() { + local name="$1" value="$2" + value="$(_ssh_monitor_config_trim "$value")" + [ -z "$value" ] && return 0 + if [[ ! "$value" =~ ^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$ ]]; then + printf 'ERROR: %s: недопустимый hostname %s\n' "$name" "$value" >&2 + return 1 + fi + return 0 +} + +# Проверка значений после load_config (exit 1 при ERROR). +ssh_monitor_validate_config_values() { + local err=0 mode item + + if [ -n "${SAC_URL:-}" ]; then + ssh_monitor_validate_http_url "SAC_URL" "${SAC_URL}" 1 || err=1 + fi + if [ -n "${MAIL_SMTP_HOST:-}" ]; then + ssh_monitor_validate_hostname "MAIL_SMTP_HOST" "${MAIL_SMTP_HOST}" || err=1 + fi + if [ -n "${SERVER_IPV4:-}" ]; then + ssh_monitor_validate_ipv4 "${SERVER_IPV4}" || { + printf 'ERROR: SERVER_IPV4: некорректный IPv4 %s\n' "${SERVER_IPV4}" >&2 + err=1 + } + fi + + mode="$(printf '%s' "${UseSAC:-off}" | tr '[:upper:]' '[:lower:]')" + case "$mode" in + off | exclusive | dual | fallback) ;; + *) + printf 'ERROR: UseSAC="%s" (допустимо: off, exclusive, dual, fallback)\n' "${UseSAC:-}" >&2 + err=1 + ;; + esac + + mode="$(printf '%s' "${GIT_VERIFY_MODE:-off}" | tr '[:upper:]' '[:lower:]')" + case "$mode" in + off | tag | commit) ;; + *) + printf 'ERROR: GIT_VERIFY_MODE="%s" (допустимо: off, tag, commit)\n' "${GIT_VERIFY_MODE:-}" >&2 + err=1 + ;; + esac + + for item in "${WHITELIST_IPS[@]:-}"; do + item="$(_ssh_monitor_config_trim "$item")" + [ -z "$item" ] && continue + if ! ssh_monitor_validate_ipv4 "$item"; then + printf 'ERROR: WHITELIST_IPS: некорректный IPv4 %s\n' "$item" >&2 + err=1 + fi + done + for item in "${WHITELIST_SUBNETS[@]:-}"; do + item="$(_ssh_monitor_config_trim "$item")" + [ -z "$item" ] && continue + if ! ssh_monitor_validate_cidr_v4 "$item"; then + printf 'ERROR: WHITELIST_SUBNETS: некорректный CIDR %s\n' "$item" >&2 + err=1 + fi + done + + return "$err" +} + +ssh_monitor_truthy() { + case "${1,,}" in + 1 | yes | true | on) return 0 ;; + esac + return 1 +} + +ssh_monitor_path_mode() { + local path="$1" + [ -e "$path" ] || return 1 + stat -c '%a' "$path" 2>/dev/null || stat -f '%OLp' "$path" 2>/dev/null +} + +ssh_monitor_path_owner() { + local path="$1" + [ -e "$path" ] || return 1 + stat -c '%U:%G' "$path" 2>/dev/null +} + +ssh_monitor_is_root_owned() { + local path="$1" own + [ -e "$path" ] || return 0 + own="$(ssh_monitor_path_owner "$path" 2>/dev/null || true)" + [ "$own" = "root:root" ] +} + +ssh_monitor_secure_dir() { + local dir="$1" + [ -n "$dir" ] || return 1 + mkdir -p "$dir" 2>/dev/null || return 1 + chmod 700 "$dir" 2>/dev/null || true + chown root:root "$dir" 2>/dev/null || true +} + +ssh_monitor_secure_file() { + local file="$1" + local dir + [ -n "$file" ] || return 1 + dir="$(dirname "$file")" + ssh_monitor_secure_dir "$dir" || true + if [ ! -f "$file" ]; then + : >"$file" 2>/dev/null || touch "$file" 2>/dev/null || return 1 + fi + chmod 600 "$file" 2>/dev/null || true + chown root:root "$file" 2>/dev/null || true +} + +# Существующий файл или каталог: 600/700 root:root; для отсутствующего пути — только родительский каталог. +ssh_monitor_harden_path() { + local path="$1" + [ -n "$path" ] || return 0 + if [ -d "$path" ]; then + ssh_monitor_secure_dir "$path" + elif [ -f "$path" ] || [ -L "$path" ]; then + ssh_monitor_secure_file "$path" + else + local parent + parent="$(dirname "$path")" + [ -n "$parent" ] && [ "$parent" != "." ] && ssh_monitor_secure_dir "$parent" + fi +} + +ssh_monitor_warn_path_perms() { + local path="$1" kind="$2" max_mode="${3:-600}" + local mode own msg + [ -e "$path" ] || return 0 + if ! ssh_monitor_is_root_owned "$path"; then + own="$(ssh_monitor_path_owner "$path" 2>/dev/null || echo '?')" + msg="WARN: ${kind} ${path}: владелец ${own}, ожидается root:root" + printf '%s\n' "$msg" >&2 + [ -n "${write_log:-}" ] && write_log "$msg" || true + fi + mode="$(ssh_monitor_path_mode "$path" 2>/dev/null || echo "")" + if [ -n "$mode" ] && [ "$((10#$mode))" -gt "$((10#$max_mode))" ]; then + msg="WARN: ${kind} ${path}: mode ${mode}, рекомендуется не шире ${max_mode}" + printf '%s\n' "$msg" >&2 + [ -n "${write_log:-}" ] && write_log "$msg" || true + fi +} + +ssh_monitor_check_config_file_perms() { + local file="${1:-/etc/ssh-monitor.conf}" + local strict="${2:-0}" + local mode msg + [ -f "$file" ] || return 0 + if ! ssh_monitor_is_root_owned "$file"; then + msg="WARN: конфиг ${file}: владелец $(ssh_monitor_path_owner "$file" 2>/dev/null || echo '?'), ожидается root:root" + printf '%s\n' "$msg" >&2 + [ -n "${write_log:-}" ] && write_log "$msg" || true + if ssh_monitor_truthy "$strict"; then + printf '%s\n' "CONFIG_STRICT_PERMS=1: исправьте владельца конфига (chown root:root ${file})" >&2 + return 1 + fi + fi + mode="$(ssh_monitor_path_mode "$file" 2>/dev/null || echo "")" + case "$mode" in + 600 | 400) ;; + *) + msg="WARN: конфиг ${file}: mode ${mode:-?}, рекомендуется 600 или 400 (chmod 600 ${file})" + printf '%s\n' "$msg" >&2 + [ -n "${write_log:-}" ] && write_log "$msg" || true + if ssh_monitor_truthy "$strict"; then + printf '%s\n' "CONFIG_STRICT_PERMS=1: ослабленные права конфига недопустимы" >&2 + return 1 + fi + ;; + esac + return 0 +} + +# Подпись сервера в Telegram/webhook (как в ssh-monitor: SERVER_DISPLAY_NAME или hostname + IPv4). +ssh_monitor_server_display_name_with_ip() { + local hn ip + hn="${SERVER_DISPLAY_NAME:-}" + if [ -z "$hn" ]; then + hn="$(hostname 2>/dev/null | tr -d '\r\n')" + [ -z "$hn" ] && hn="(unknown)" + fi + ip="${SERVER_IPV4:-}" + if [ -z "$ip" ] && 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 && 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 + if [ -n "$ip" ]; then + printf '%s (%s)' "$hn" "$ip" + else + printf '%s' "$hn" + fi +} + +ssh_monitor_message_ensure_server_line() { + local msg="$1" + if [[ "$msg" == *'🖥️ Сервер:'* ]]; then + printf '%s' "$msg" + return 0 + fi + local first rest + first="${msg%%$'\n'*}" + if [ "$first" = "$msg" ]; then + printf '%s\n🖥️ Сервер: %s' "$first" "$(ssh_monitor_server_display_name_with_ip)" + else + rest="${msg#*$'\n'}" + printf '%s\n🖥️ Сервер: %s\n%s' "$first" "$(ssh_monitor_server_display_name_with_ip)" "$rest" + fi +} + +# Флаг штатного SAC/cron-update: подавляет lifecycle/watchdog Telegram на время прогона updater. +SSH_MONITOR_UPDATE_STATE_DEFAULT="/var/lib/ssh-monitor/agent-update-in-progress" +SSH_MONITOR_UPDATE_STATE_MAX_AGE_SEC="${SSH_MONITOR_UPDATE_STATE_MAX_AGE_SEC:-900}" + +ssh_monitor_update_state_file() { + printf '%s\n' "${SSH_MONITOR_UPDATE_STATE_FILE:-$SSH_MONITOR_UPDATE_STATE_DEFAULT}" +} + +ssh_monitor_update_in_progress() { + local f ts now age + f="$(ssh_monitor_update_state_file)" + [ -f "$f" ] || return 1 + ts="$(tr -d '\r\n' <"$f" 2>/dev/null || true)" + [[ "$ts" =~ ^[0-9]+$ ]] || return 1 + now=$(date '+%s') + age=$((now - ts)) + [ "$age" -le "$SSH_MONITOR_UPDATE_STATE_MAX_AGE_SEC" ] +} + +ssh_monitor_update_mark_begin() { + local f dir + f="$(ssh_monitor_update_state_file)" + dir="$(dirname "$f")" + ssh_monitor_secure_dir "$dir" 2>/dev/null || mkdir -p "$dir" 2>/dev/null || true + date '+%s' >"$f" + ssh_monitor_secure_file "$f" 2>/dev/null || chmod 600 "$f" 2>/dev/null || true +} + +ssh_monitor_update_mark_end() { + local f + f="$(ssh_monitor_update_state_file)" + rm -f "$f" 2>/dev/null || true +} + +# SAC/cron update: не слать sudo-alert на штатные команды updater/bootstrap. +ssh_monitor_sudo_is_sac_maintenance() { + local cmd="$1" + [ -n "$cmd" ] || return 1 + if ssh_monitor_update_in_progress 2>/dev/null; then + return 0 + fi + case "$cmd" in + *update_ssh_monitor.sh* | *UPDATE_VIA_SAC* | *update_script.log* | \ + *"/opt/scripts/update"*"ssh-monitor"* | *agent-update-in-progress*) + return 0 + ;; + esac + return 1 +} diff --git a/ssh-monitor-watchdog b/ssh-monitor-watchdog new file mode 100644 index 0000000..960c6d3 --- /dev/null +++ b/ssh-monitor-watchdog @@ -0,0 +1,133 @@ +#!/bin/bash +set -Eeuo pipefail +IFS=$'\n\t' + +CONFIG_FILE="/etc/ssh-monitor.conf" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [ -f "$SCRIPT_DIR/ssh-monitor-perms.sh" ]; then + # shellcheck source=ssh-monitor-perms.sh + source "$SCRIPT_DIR/ssh-monitor-perms.sh" +fi +WATCHDOG_LOG_FILE="/var/log/ssh_monitor_watchdog.log" +LAST_HEARTBEAT_FILE="/var/log/last_heartbeat.txt" +WATCHDOG_MAX_HEARTBEAT_AGE=18000 +WATCHDOG_SERVICE_NAME="ssh-monitor.service" +WATCHDOG_NOTIFY_ON_RECOVERY="0" + +write_log() { + local message="$1" + local timestamp + timestamp="$(date '+%Y-%m-%d %H:%M:%S')" + echo "${timestamp} - ${message}" | tee -a "$WATCHDOG_LOG_FILE" >/dev/null +} + +send_telegram_raw() { + local message="$1" + if [ -z "${TELEGRAM_BOT_TOKEN:-}" ] || [ -z "${TELEGRAM_CHAT_ID:-}" ]; then + return 1 + fi + + local url="https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" + local body + if ! body=$(curl -fsS -X POST "$url" \ + --data-urlencode "chat_id=${TELEGRAM_CHAT_ID}" \ + --data-urlencode "text=${message}" \ + --data-urlencode "parse_mode=HTML" \ + --max-time 10 2>/dev/null); then + return 1 + fi + if echo "$body" | grep -q '"ok":true'; then + return 0 + fi + return 1 +} + +notify_send() { + local message="$1" + message="$(ssh_monitor_message_ensure_server_line "$message")" + send_telegram_raw "$message" +} + +load_config() { + if [ -f "$CONFIG_FILE" ]; then + local _strict_peek + _strict_peek="$(ssh_monitor_config_peek CONFIG_STRICT_PERMS "$CONFIG_FILE" "0")" + ssh_monitor_check_config_file_perms "$CONFIG_FILE" "$_strict_peek" || true + ssh_monitor_config_load_file "$CONFIG_FILE" || true + fi +} + +is_service_active() { + systemctl is-active --quiet "$WATCHDOG_SERVICE_NAME" +} + +heartbeat_age_seconds() { + if [ ! -f "$LAST_HEARTBEAT_FILE" ]; then + echo 999999 + return 0 + fi + + local hb now + hb="$(<"$LAST_HEARTBEAT_FILE")" + now="$(date '+%s')" + if [[ ! "$hb" =~ ^[0-9]+$ ]]; then + echo 999999 + return 0 + fi + echo $((now - hb)) +} + +restart_monitor() { + local reason="$1" + write_log "Watchdog: перезапуск $WATCHDOG_SERVICE_NAME, причина: $reason" + if systemctl restart "$WATCHDOG_SERVICE_NAME"; then + local msg=" ⚠️ WATCHDOG: монитор перезапущен "$'\n' + msg="${msg}Сервис: ${WATCHDOG_SERVICE_NAME}"$'\n' + msg="${msg}Причина: ${reason}"$'\n' + msg="${msg}Время: $(date '+%d.%m.%Y %H:%M:%S')" + notify_send "$msg" + else + write_log "Watchdog: ошибка перезапуска $WATCHDOG_SERVICE_NAME" + local msg=" ❌ WATCHDOG: не удалось перезапустить монитор "$'\n' + msg="${msg}Сервис: ${WATCHDOG_SERVICE_NAME}"$'\n' + msg="${msg}Причина: ${reason}"$'\n' + msg="${msg}Время: $(date '+%d.%m.%Y %H:%M:%S')" + notify_send "$msg" + return 1 + fi +} + +main() { + if [ "$EUID" -ne 0 ]; then + echo "Запустите watchdog от root" + exit 1 + fi + + load_config + mkdir -p "$(dirname "$WATCHDOG_LOG_FILE")" + touch "$WATCHDOG_LOG_FILE" + + if ssh_monitor_update_in_progress 2>/dev/null; then + write_log "Watchdog: пропуск — идёт agent-update (state file)" + exit 0 + fi + + local reason="" + if ! is_service_active; then + reason="systemd reported inactive service" + else + local age + age="$(heartbeat_age_seconds)" + if [ "$age" -ge "${WATCHDOG_MAX_HEARTBEAT_AGE:-5400}" ]; then + reason="stale heartbeat (${age}s)" + fi + fi + + if [ -n "$reason" ]; then + restart_monitor "$reason" + elif [ "${WATCHDOG_NOTIFY_ON_RECOVERY:-1}" = "1" ]; then + write_log "Watchdog: OK, сервис активен и heartbeat актуален" + fi +} + +main "$@" diff --git a/ssh-monitor-watchdog.service.example b/ssh-monitor-watchdog.service.example new file mode 100644 index 0000000..8a6a840 --- /dev/null +++ b/ssh-monitor-watchdog.service.example @@ -0,0 +1,9 @@ +[Unit] +Description=Watchdog for ssh-monitor service +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/ssh-monitor-watchdog +User=root diff --git a/ssh-monitor-watchdog.timer.example b/ssh-monitor-watchdog.timer.example new file mode 100644 index 0000000..3b3a373 --- /dev/null +++ b/ssh-monitor-watchdog.timer.example @@ -0,0 +1,11 @@ +[Unit] +Description=Run ssh-monitor watchdog every minute + +[Timer] +OnBootSec=2min +OnUnitActiveSec=1min +Unit=ssh-monitor-watchdog.service +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/ssh-monitor.conf.example b/ssh-monitor.conf.example new file mode 100644 index 0000000..2ae2b25 --- /dev/null +++ b/ssh-monitor.conf.example @@ -0,0 +1,124 @@ +# Закрытое зеркало — доверенный инстанс; в публичном GitHub используйте example без секретов. +TELEGRAM_BOT_TOKEN="8239219522:AAEyOZX3cwNfgGOMDkf-mgjTIuoaOh5gF7I" +TELEGRAM_CHAT_ID="2843230" + +# --- Очередь оповещений (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="" + +# --- Security Alert Center (SAC) --- +# off | exclusive | dual | fallback — см. docs в security-alert-center (agent-integration.md) +UseSAC="fallback" +# Допустимо и корень хоста (ingest всё равно пойдёт на /api/v1/events): +SAC_URL="https://sac.kalinamall.ru" +SAC_API_KEY="sac_UkOsAT3UWiQS54KK5OJPBDCSucysQDrKFju28wmYiz8" +SAC_SPOOL_DIR="/var/lib/ssh-monitor/sac-spool" +SAC_FAIL_COUNT_FILE="/var/lib/ssh-monitor/sac-fail.count" +SAC_FALLBACK_FAILURES="5" +# Таймаут curl к SAC (health + ingest). При burst ingest на сервере — 60–90; см. docs/sac-ingest.ru.md +SAC_TIMEOUT_SEC="45" +SAC_SPOOL_FLUSH_MAX_FILES="50" +SAC_SPOOL_MAX_AGE_HOURS="72" + +# 0 = проверять TLS SAC (рекомендуется). 1 = curl -k (только lab; CRITICAL в логе). +SAC_TLS_INSECURE="0" +# 0 = WARN при chmod конфига != 600. 1 = exit 1 при ослабленных правах /etc/ssh-monitor.conf. +CONFIG_STRICT_PERMS="0" + +# SMTP (python3). Для авто-режима почты нужны MAIL_SMTP_HOST, MAIL_FROM, MAIL_TO. +MAIL_SMTP_HOST="" +MAIL_SMTP_PORT="587" +MAIL_SMTP_USER="" +MAIL_SMTP_PASSWORD="" +MAIL_FROM="" +MAIL_TO="" +MAIL_SMTP_STARTTLS="1" +MAIL_SMTP_SSL="0" + +LOG_FILE="/var/log/ssh_monitor.log" +LAST_HEARTBEAT_FILE="/var/log/last_heartbeat.txt" +# Интервал SAC agent.heartbeat (минуты). 240 = 4 ч. +HEARTBEAT_INTERVAL_MINUTES="240" +LAST_REPORT_FILE="/var/log/last_daily_report.txt" +LAST_SSH_CHECK_FILE="/var/log/last_ssh_check" +LAST_SUDO_CHECK_FILE="/var/log/last_sudo_check" +LAST_SECURITY_EVENTS_FILE="/var/log/last_security_events_check" +LAST_LOGIND_CHECK_FILE="/var/log/last_logind_check" +BAN_LIST_FILE="/var/log/ssh_bans.txt" + +# 1 = мониторинг + автобан IP (нужен ipset). 0 = только мониторинг и уведомления (логины, неудачи, sudo, logind и т.д. — без ipset/iptables). +ENABLE_IP_BAN="1" + +# 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" +# 0 = не слать report.daily.ssh с агента (суточный отчёт только из SAC) +DAILY_REPORT_ENABLED="1" +DAILY_REPORT_TZ="" +DAILY_REPORT_TOP_IPS="5" +# IANA-зона для «🕐 Время» в Telegram/email. Пусто = как DAILY_REPORT_TZ, иначе зона процесса (часто UTC в systemd — см. README). +NOTIFY_TZ="" +# Подпись «🖥️ Сервер» в уведомлениях: пусто = hostname (+ IPv4); иначе фиксированное имя (например prod-web-01). +# SERVER_DISPLAY_NAME="prod-web-01" +# Явный IPv4 для SAC payload host.ipv4. Пусто = автоопределение. +# SERVER_IPV4="10.10.20.31" +# Не чаще 1 успешного SSH (Accepted) в Telegram на пару user+IP за N секунд (разные port в журнале). 0 = только дедуп внутри одного прохода. +SSH_ACCEPT_NOTIFY_DEDUP_SEC="5" + +BAN_TIME="3600" +MAX_ATTEMPTS="3" +BAN_CHECK_INTERVAL="60" +MONITOR_INTERVAL="10" + +# --- ipset (скрипт в этой папке): имена наборов и лимит элементов. Пусто в конфиге = значения по умолчанию из скрипта. +# IPSET_NAME_V4="ssh_monitor_ban" +# IPSET_NAME_V6="ssh_monitor_ban_v6" +# IPSET_MAXELEM="1048576" + +# Окно и порог для уведомления о возможном массовом брутфорсе (journalctl) +BRUTE_WINDOW_SEC="900" +BRUTE_MIN_FAILS="30" +BRUTE_NOTIFY_COOLDOWN_SEC="3600" + +# Директория для textfile collector Prometheus (node_exporter). Пусто — отключено. +PROMETHEUS_TEXTFILE_DIR="" + +# JSON-снимок состояния на каждой итерации (timestamp, hostname, dry_run). Пусто — отключено. +HEALTHCHECK_STATUS_FILE="" + +# --- Автообновление (update_ssh_monitor.sh; читается updater'ом, не агентом) --- +# Пусто = ветка GIT_BRANCH (main). Для прода рекомендуется тег релиза, напр. 2.2.0-SAC. +# GIT_REF="2.2.0-SAC" +# off | tag | commit — см. docs/auto-update.ru.md. Дефолт в скрипте: off (совместимость). +# GIT_VERIFY_MODE="tag" +# 1 = разрешить git reset --hard при расхождении истории (только ручной/CI; риск). +# GIT_ALLOW_RESET="0" + +# Порог устаревания heartbeat для watchdog (сек): интервал + 1 ч запас (240 мин → 18000). +WATCHDOG_MAX_HEARTBEAT_AGE="18000" +WATCHDOG_LOG_FILE="/var/log/ssh_monitor_watchdog.log" +WATCHDOG_SERVICE_NAME="ssh-monitor.service" +WATCHDOG_NOTIFY_ON_RECOVERY="0" + +WHITELIST_IPS="127.0.0.1,10.0.0.5" +WHITELIST_SUBNETS="10.0.0.0/24,192.168.1.0/24" diff --git a/ssh-monitor.service.example b/ssh-monitor.service.example new file mode 100644 index 0000000..796856b --- /dev/null +++ b/ssh-monitor.service.example @@ -0,0 +1,14 @@ +[Unit] +Description=SSH Monitor with Telegram alerts and SAC integration +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/ssh-monitor +Restart=always +RestartSec=5 +User=root + +[Install] +WantedBy=multi-user.target diff --git a/update_ssh_monitor.sh b/update_ssh_monitor.sh new file mode 100644 index 0000000..3ea1e9b --- /dev/null +++ b/update_ssh_monitor.sh @@ -0,0 +1,1823 @@ +#!/bin/bash + +# === Конфигурация скрипта === +UPDATE_DIR="/opt/scripts/update" +SCRIPT_NAME="ssh-monitor" +REPO_URL="${REPO_URL:-}" +GIT_BRANCH="${GIT_BRANCH:-main}" +GIT_REF="${GIT_REF:-}" +GIT_VERIFY_MODE="${GIT_VERIFY_MODE:-off}" +GIT_ALLOW_RESET="${GIT_ALLOW_RESET:-0}" +MANIFEST_VERIFIED=0 +MANIFEST_STRICT=0 +LOCAL_SCRIPT_PATH="/usr/local/bin/ssh-monitor" +LOG_FILE="/var/log/update_script.log" + +# Режим отладки: 1 = включён, 0 = выключен +DEBUG=0 +DEPLOY_MODE=0 +QUIET=0 + +# Итог последнего прогона (для лога и консоли) +SUMMARY_SCRIPT="—" +SUMMARY_SCRIPT_REASON="—" +SUMMARY_SAC_CLIENT="—" +SUMMARY_SAC_REASON="—" +SUMMARY_CONFIG="—" +SUMMARY_GIT="—" +SUMMARY_SERVICE_RESTART="—" +SUMMARY_UPDATER="—" +SUMMARY_WATCHDOG="—" +SUMMARY_HARDENING="—" +SUMMARY_MANIFEST="—" + +CONFIG_FILE="/etc/ssh-monitor.conf" +DEPLOY_INSTALL_PATH="/opt/scripts/update_ssh_monitor.sh" +EXPECTED_HEARTBEAT_INTERVAL_MINUTES=240 +EXPECTED_WATCHDOG_MAX_HEARTBEAT_AGE=$((EXPECTED_HEARTBEAT_INTERVAL_MINUTES * 60 + 3600)) +SAC_SPOOL_DIR_DEFAULT="/var/lib/ssh-monitor/sac-spool" +SYSTEMD_UNIT_PATH="/etc/systemd/system/ssh-monitor.service" +LOCAL_WATCHDOG_PATH="/usr/local/bin/ssh-monitor-watchdog" +WATCHDOG_SERVICE_PATH="/etc/systemd/system/ssh-monitor-watchdog.service" +WATCHDOG_TIMER_PATH="/etc/systemd/system/ssh-monitor-watchdog.timer" + +# === Список переменных, объявленных в скрипте (для логирования при DEBUG=1) === +SCRIPT_VARS=("UPDATE_DIR" "SCRIPT_NAME" "REPO_URL" "GIT_BRANCH" "LOCAL_SCRIPT_PATH" "LOG_FILE" "DEBUG" "REMOTE_SCRIPT_PATH" "LOCAL_CHECKSUM" "REMOTE_CHECKSUM") + +# === Функции === + +say_step() { + printf '\n==> %s\n' "$1" +} + +say_ok() { + printf '[OK] %s\n' "$1" + log_message "INFO" "$1" +} + +say_warn() { + printf '[WARN] %s\n' "$1" + log_message "INFO" "WARNING: $1" +} + +say_info() { + printf ' %s\n' "$1" +} + +show_help() { + cat <<'EOF' +update_ssh_monitor.sh — обновление или первичная установка ssh-monitor + +Использование: + sudo ./update_ssh_monitor.sh обновление (git pull + checksum) + sudo ./update_ssh_monitor.sh --deploy первичная установка / полный deploy + sudo ./first_deploy.sh то же, что --deploy + +Опции: + --deploy установить ssh-monitor, sac-client, пример конфига, systemd unit + --debug подробный лог в /var/log/update_script.log + --quiet не печатать шаги в stdout (только в $LOG_FILE) + -h, --help эта справка + +Лог действий: /var/log/update_script.log (в конце каждого прогона — блок «Итог обновления»). + +При первой установке создаётся /etc/ssh-monitor.conf из example. +При обновлении в существующий конфиг дописываются отсутствующие SAC-переменные +и обязательные ключи безопасности (CONFIG_STRICT_PERMS, SAC_TLS_INSECURE и т.д.); +выставляется UseSAC=fallback (Telegram/SMTP не перезаписываются). +На каждом прогоне автоматически выставляются chmod/chown на конфиг, spool и state-файлы +(основной сценарий — кнопка «Обновить» в SAC; cron/timer — опционально). + +Переменная REPO_URL обязательна (export, SAC или Environment= в systemd/cron). +EOF +} + +require_repo_url() { + if [ -n "${REPO_URL:-}" ]; then + return 0 + fi + local msg="ERROR: REPO_URL не задан. Задайте export REPO_URL=https://git.papatramp.ru/PapaTramp/ssh-monitor.git или Environment=REPO_URL=... в systemd unit / cron." + echo "$msg" >&2 + mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true + echo "$(date '+%Y-%m-%d %H:%M:%S') ERROR: REPO_URL не задан" >>"$LOG_FILE" 2>/dev/null || true + exit 1 +} + +default_required_config_value() { + case "$1" in + CONFIG_STRICT_PERMS) printf '0' ;; + SAC_TLS_INSECURE) printf '0' ;; + *) printf '' ;; + esac +} + +# Ключи SAC: дописываются из ssh-monitor.conf.example, если отсутствуют в /etc. +SAC_CONFIG_KEYS=( + UseSAC + SAC_URL + SAC_API_KEY + SAC_SPOOL_DIR + SAC_FAIL_COUNT_FILE + SAC_FALLBACK_FAILURES + SAC_TIMEOUT_SEC + SAC_SPOOL_FLUSH_MAX_FILES + SAC_SPOOL_MAX_AGE_HOURS +) +SAC_USE_MODE_DEFAULT="fallback" + +# Обязательные ключи конфига (добавляются updater'ом, если отсутствуют). +REQUIRED_CONFIG_KEYS=( + CONFIG_STRICT_PERMS + SAC_TLS_INSECURE +) + +find_ssh_monitor_conf_example() { + local example="$UPDATE_DIR/$SCRIPT_NAME/ssh-monitor.conf.example" + if [ -f "$example" ]; then + printf '%s\n' "$example" + return 0 + fi + return 1 +} + +config_conf_value() { + local key="$1" file="$2" line val + [ -f "$file" ] || return 1 + line=$(grep -E "^[[:space:]]*${key}=" "$file" 2>/dev/null | tail -1) || return 1 + val=${line#*=} + val=${val#"${val%%[![:space:]]*}"} + val=${val%"${val##*[![:space:]]}"} + val=${val#\"} + val=${val%\"} + val=${val#\'} + val=${val%\'} + printf '%s\n' "$val" +} + +config_example_value() { + local key="$1" example="$2" + config_conf_value "$key" "$example" 2>/dev/null || true +} + +backup_config_if_needed() { + local file="$1" flag_var="$2" + if [ -z "${!flag_var:-}" ] || [ "${!flag_var}" -eq 0 ]; then + cp "$file" "${file}.bak.$(date +%Y%m%d_%H%M%S)" 2>/dev/null || true + printf -v "$flag_var" '%s' 1 + fi +} + +ensure_ssh_monitor_config_file() { + local example + CONFIG_CHANGED=0 + + example=$(find_ssh_monitor_conf_example) || { + log_message "INFO" "WARNING: ssh-monitor.conf.example not found in clone" + return 0 + } + + if [ -f "$CONFIG_FILE" ]; then + if [ "$DEPLOY_MODE" -eq 1 ]; then + say_info "Уже существует: $CONFIG_FILE (дополним SAC-переменные при необходимости)" + fi + return 0 + fi + + log_message "INFO" "Creating $CONFIG_FILE from ssh-monitor.conf.example" + if [ "$DEPLOY_MODE" -eq 1 ]; then + say_ok "Создан $CONFIG_FILE из ssh-monitor.conf.example" + fi + if ! cp "$example" "$CONFIG_FILE"; then + log_message "INFO" "ERROR: Failed to copy ssh-monitor.conf.example to $CONFIG_FILE" + return 1 + fi + chmod 600 "$CONFIG_FILE" 2>/dev/null || true + chown root:root "$CONFIG_FILE" 2>/dev/null || true + CONFIG_CHANGED=1 + return 0 +} + +sync_sac_config_keys() { + local file="$CONFIG_FILE" + local example backed_up=0 + local key val current changed=0 added=0 + + [ -f "$file" ] || return 1 + example=$(find_ssh_monitor_conf_example) || { + log_message "INFO" "WARNING: ssh-monitor.conf.example not found; SAC merge skipped" + return 0 + } + + if ! config_key_exists "UseSAC" "$file"; then + backup_config_if_needed "$file" backed_up + { + printf '\n# --- Security Alert Center (SAC) ---\n' + printf '# off | exclusive | dual | fallback — см. security-alert-center docs/agent-integration.md\n' + } >>"$file" + log_message "INFO" "Config $file: добавлен блок SAC (ключи отсутствовали)." + changed=1 + fi + + for key in "${SAC_CONFIG_KEYS[@]}"; do + if config_key_exists "$key" "$file"; then + continue + fi + val=$(config_example_value "$key" "$example") + if [ -z "$val" ] && [ "$key" != "SAC_API_KEY" ]; then + case "$key" in + UseSAC) val="$SAC_USE_MODE_DEFAULT" ;; + SAC_SPOOL_DIR) val="$SAC_SPOOL_DIR_DEFAULT" ;; + SAC_FAIL_COUNT_FILE) val="/var/lib/ssh-monitor/sac-fail.count" ;; + SAC_FALLBACK_FAILURES) val="5" ;; + SAC_TIMEOUT_SEC) val="45" ;; + SAC_SPOOL_FLUSH_MAX_FILES) val="50" ;; + SAC_SPOOL_MAX_AGE_HOURS) val="72" ;; + SAC_URL) val="https://sac.kalinamall.ru" ;; + esac + fi + backup_config_if_needed "$file" backed_up + set_config_kv "$key" "$val" "$file" + log_message "INFO" "Config $file: добавлен ${key}=${val:-<пусто>}" + changed=1 + added=$((added + 1)) + done + + current=$(config_conf_value "UseSAC" "$file" 2>/dev/null || true) + if [ "$current" != "$SAC_USE_MODE_DEFAULT" ]; then + backup_config_if_needed "$file" backed_up + set_config_kv "UseSAC" "$SAC_USE_MODE_DEFAULT" "$file" + log_message "INFO" "Config $file: UseSAC ${current:-<нет>} → $SAC_USE_MODE_DEFAULT (deploy policy)." + changed=1 + fi + + current=$(config_conf_value "SAC_TIMEOUT_SEC" "$file" 2>/dev/null || true) + if [ "$current" = "12" ]; then + backup_config_if_needed "$file" backed_up + set_config_kv "SAC_TIMEOUT_SEC" "45" "$file" + log_message "INFO" "Config $file: SAC_TIMEOUT_SEC 12 → 45 (ingest timeout)." + changed=1 + fi + + if [ "$changed" -eq 0 ]; then + return 1 + fi + + if [ "$DEPLOY_MODE" -eq 1 ]; then + if [ "$added" -gt 0 ]; then + say_info "SAC: добавлено ключей — $added; UseSAC=$SAC_USE_MODE_DEFAULT." + else + say_info "SAC: UseSAC=$SAC_USE_MODE_DEFAULT." + fi + fi + CONFIG_CHANGED=1 + return 0 +} + +sync_required_config_keys() { + local file="$CONFIG_FILE" + local example backed_up=0 + local key val changed=0 + + [ -f "$file" ] || return 1 + example=$(find_ssh_monitor_conf_example) || example="" + + for key in "${REQUIRED_CONFIG_KEYS[@]}"; do + if config_key_exists "$key" "$file"; then + continue + fi + val="$(config_example_value "$key" "$example")" + if [ -z "$val" ]; then + val="$(default_required_config_value "$key")" + fi + backup_config_if_needed "$file" backed_up + if ! grep -qE '^[[:space:]]*# --- Security / updater ---' "$file" 2>/dev/null; then + printf '\n# --- Security / updater (добавлено update_ssh_monitor.sh) ---\n' >>"$file" + fi + set_config_kv "$key" "$val" "$file" + log_message "INFO" "Config $file: добавлен обязательный ${key}=${val}" + changed=1 + done + + if [ "$changed" -eq 0 ]; then + return 1 + fi + CONFIG_CHANGED=1 + return 0 +} + +harden_ssh_monitor_config_perms() { + [ -f "$CONFIG_FILE" ] || return 0 + chmod 600 "$CONFIG_FILE" 2>/dev/null || true + chown root:root "$CONFIG_FILE" 2>/dev/null || true +} + +load_ssh_monitor_perms_lib() { + local p candidates=() + candidates+=("${LOCAL_SCRIPT_PATH%/*}/ssh-monitor-perms.sh") + candidates+=("$UPDATE_DIR/$SCRIPT_NAME/ssh-monitor-perms.sh") + for p in "${candidates[@]}"; do + if [ -f "$p" ]; then + # shellcheck source=/dev/null + source "$p" + return 0 + fi + done + return 1 +} + +# SAC-first: при каждом обновлении (SAC, cron или вручную) выставляет chmod/chown на конфиг, +# spool, счётчики SAC и state-файлы из /etc/ssh-monitor.conf — без участия админа. +apply_runtime_security_hardening() { + local spool fail_count agent_id path key + local -a state_keys defaults + local i=0 + + log_action "security: применение прав (конфиг, spool, state) — основной путь через SAC/update" + harden_ssh_monitor_config_perms + chmod 750 "$DEPLOY_INSTALL_PATH" 2>/dev/null || true + chown root:root "$DEPLOY_INSTALL_PATH" 2>/dev/null || true + + if ! load_ssh_monitor_perms_lib; then + log_action "WARNING: ssh-monitor-perms.sh недоступен — только chmod конфига и базовых каталогов" + mkdir -p /var/lib/ssh-monitor "$SAC_SPOOL_DIR_DEFAULT" 2>/dev/null || true + chmod 700 /var/lib/ssh-monitor "$SAC_SPOOL_DIR_DEFAULT" 2>/dev/null || true + chown root:root /var/lib/ssh-monitor "$SAC_SPOOL_DIR_DEFAULT" 2>/dev/null || true + SUMMARY_HARDENING="частично (нет perms lib)" + return 0 + fi + + ssh_monitor_secure_dir "/var/lib/ssh-monitor" + + spool="$(config_conf_value SAC_SPOOL_DIR "$CONFIG_FILE" 2>/dev/null || echo "$SAC_SPOOL_DIR_DEFAULT")" + fail_count="$(config_conf_value SAC_FAIL_COUNT_FILE "$CONFIG_FILE" 2>/dev/null || echo "/var/lib/ssh-monitor/sac-fail.count")" + agent_id="$(config_conf_value SAC_AGENT_ID_FILE "$CONFIG_FILE" 2>/dev/null || echo "/var/lib/ssh-monitor/agent_instance_id")" + + ssh_monitor_harden_path "$spool" + ssh_monitor_harden_path "$fail_count" + ssh_monitor_harden_path "$agent_id" + + state_keys=( + 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 + HEALTHCHECK_STATUS_FILE + ) + defaults=( + /var/log/last_heartbeat.txt + /var/log/last_daily_report.txt + /var/log/last_ssh_check + /var/log/last_sudo_check + /var/log/last_security_events_check + /var/log/last_logind_check + /var/log/ssh_bans.txt + "" + ) + for key in "${state_keys[@]}"; do + path="$(config_conf_value "$key" "$CONFIG_FILE" 2>/dev/null || true)" + if [ -z "$path" ]; then + path="${defaults[$i]}" + fi + if [ -n "$path" ]; then + ssh_monitor_harden_path "$path" + fi + i=$((i + 1)) + done + + if [ -f /etc/ssh_monitor_whitelist.txt ]; then + ssh_monitor_harden_path /etc/ssh_monitor_whitelist.txt + fi + + SUMMARY_HARDENING="права применены" + log_action "security: runtime paths hardened (spool, state, config)" +} + +maybe_enable_deploy_for_missing_install() { + if [ "$DEPLOY_MODE" -eq 1 ]; then + return 0 + fi + if [ -f "$LOCAL_SCRIPT_PATH" ]; then + return 0 + fi + log_action "ssh-monitor не найден в $LOCAL_SCRIPT_PATH — автоматический режим --deploy" + DEPLOY_MODE=1 +} + +set_config_kv() { + local key="$1" val="$2" file="$3" esc + esc=$(printf '%s' "$val" | sed -e 's/[\\&/]/\\&/g' -e 's/"/\\"/g') + if grep -qE "^[[:space:]]*${key}=" "$file" 2>/dev/null; then + sed -i -E "s/^[[:space:]]*${key}=.*/${key}=\"${esc}\"/" "$file" + else + printf '%s="%s"\n' "$key" "$esc" >>"$file" + fi +} + +config_key_exists() { + local key="$1" file="$2" + grep -qE "^[[:space:]]*${key}=" "$file" 2>/dev/null +} + +config_key_mentioned() { + local key="$1" file="$2" + grep -qE "^[[:space:]]*(#.*)?${key}=" "$file" 2>/dev/null +} + +sync_server_ipv4_hint_config() { + local file="$CONFIG_FILE" backed_up=0 + + [ -f "$file" ] || return 1 + if config_key_mentioned "SERVER_IPV4" "$file"; then + return 1 + fi + + backup_config_if_needed "$file" backed_up + { + printf '\n# --- Явный IPv4 для SAC host.ipv4 (раскомментируйте при необходимости) ---\n' + printf '# SERVER_IPV4="10.0.0.10"\n' + } >>"$file" + log_message "INFO" "Config $file: добавлена подсказка # SERVER_IPV4." + CONFIG_CHANGED=1 + return 0 +} + +sync_daily_report_enabled_config() { + local file="$CONFIG_FILE" backed_up=0 current + + [ -f "$file" ] || return 1 + if config_key_exists "DAILY_REPORT_ENABLED" "$file"; then + current=$(config_conf_value "DAILY_REPORT_ENABLED" "$file" 2>/dev/null || true) + if [ "$current" = "1" ]; then + return 1 + fi + backup_config_if_needed "$file" backed_up + set_config_kv "DAILY_REPORT_ENABLED" "1" "$file" + log_message "INFO" "Config $file: DAILY_REPORT_ENABLED ${current:-<пусто>} → 1 (отчёт с агента)." + else + backup_config_if_needed "$file" backed_up + { + printf '\n# --- Суточный отчёт report.daily.ssh (1 = с агента; 0 = только SAC) ---\n' + printf 'DAILY_REPORT_ENABLED="1"\n' + } >>"$file" + log_message "INFO" "Config $file: добавлен DAILY_REPORT_ENABLED=1." + fi + CONFIG_CHANGED=1 + return 0 +} + +append_heartbeat_interval_block() { + local file="$1" + { + printf '\n# --- SAC agent.heartbeat (интервал в минутах; %s = 4 ч) ---\n' "$EXPECTED_HEARTBEAT_INTERVAL_MINUTES" + printf 'HEARTBEAT_INTERVAL_MINUTES="%s"\n' "$EXPECTED_HEARTBEAT_INTERVAL_MINUTES" + printf '# Порог watchdog: интервал + 1 ч запас (%s с)\n' "$EXPECTED_WATCHDOG_MAX_HEARTBEAT_AGE" + printf 'WATCHDOG_MAX_HEARTBEAT_AGE="%s"\n' "$EXPECTED_WATCHDOG_MAX_HEARTBEAT_AGE" + } >>"$file" +} + +sync_heartbeat_interval_config() { + local file="$CONFIG_FILE" + local current_interval current_watchdog + local changed=0 + local backed_up=0 + + [ -f "$file" ] || return 1 + + backup_config_once() { + if [ "$backed_up" -eq 0 ]; then + cp "$file" "${file}.bak.$(date +%Y%m%d_%H%M%S)" 2>/dev/null || true + backed_up=1 + fi + } + + if ! config_key_exists "HEARTBEAT_INTERVAL_MINUTES" "$file"; then + backup_config_once + if ! config_key_exists "WATCHDOG_MAX_HEARTBEAT_AGE" "$file"; then + append_heartbeat_interval_block "$file" + log_message "INFO" "Config $file: добавлены HEARTBEAT_INTERVAL_MINUTES=$EXPECTED_HEARTBEAT_INTERVAL_MINUTES и WATCHDOG_MAX_HEARTBEAT_AGE=$EXPECTED_WATCHDOG_MAX_HEARTBEAT_AGE (отсутствовали в конфиге)." + else + set_config_kv "HEARTBEAT_INTERVAL_MINUTES" "$EXPECTED_HEARTBEAT_INTERVAL_MINUTES" "$file" + log_message "INFO" "Config $file: добавлен HEARTBEAT_INTERVAL_MINUTES=$EXPECTED_HEARTBEAT_INTERVAL_MINUTES (отсутствовал в конфиге)." + fi + changed=1 + else + current_interval=$(config_conf_value "HEARTBEAT_INTERVAL_MINUTES" "$file" 2>/dev/null || true) + if [ "$current_interval" != "$EXPECTED_HEARTBEAT_INTERVAL_MINUTES" ]; then + backup_config_once + set_config_kv "HEARTBEAT_INTERVAL_MINUTES" "$EXPECTED_HEARTBEAT_INTERVAL_MINUTES" "$file" + log_message "INFO" "Config $file: HEARTBEAT_INTERVAL_MINUTES ${current_interval:-?} → $EXPECTED_HEARTBEAT_INTERVAL_MINUTES (deploy policy)." + changed=1 + fi + fi + + if ! config_key_exists "WATCHDOG_MAX_HEARTBEAT_AGE" "$file"; then + backup_config_once + set_config_kv "WATCHDOG_MAX_HEARTBEAT_AGE" "$EXPECTED_WATCHDOG_MAX_HEARTBEAT_AGE" "$file" + log_message "INFO" "Config $file: добавлен WATCHDOG_MAX_HEARTBEAT_AGE=$EXPECTED_WATCHDOG_MAX_HEARTBEAT_AGE (отсутствовал в конфиге)." + changed=1 + else + current_watchdog=$(config_conf_value "WATCHDOG_MAX_HEARTBEAT_AGE" "$file" 2>/dev/null || true) + if [ "$current_watchdog" != "$EXPECTED_WATCHDOG_MAX_HEARTBEAT_AGE" ]; then + backup_config_once + set_config_kv "WATCHDOG_MAX_HEARTBEAT_AGE" "$EXPECTED_WATCHDOG_MAX_HEARTBEAT_AGE" "$file" + log_message "INFO" "Config $file: WATCHDOG_MAX_HEARTBEAT_AGE ${current_watchdog:-?} → $EXPECTED_WATCHDOG_MAX_HEARTBEAT_AGE (deploy policy)." + changed=1 + fi + fi + + if [ "$changed" -eq 0 ]; then + return 1 + fi + + if [ "$DEPLOY_MODE" -eq 1 ]; then + say_info "Heartbeat: ${EXPECTED_HEARTBEAT_INTERVAL_MINUTES} мин, watchdog ${EXPECTED_WATCHDOG_MAX_HEARTBEAT_AGE} с." + fi + CONFIG_CHANGED=1 + return 0 +} + +parse_command_line() { + DEPLOY_MODE=0 + while [ $# -gt 0 ]; do + case "$1" in + --deploy) + DEPLOY_MODE=1 + shift + ;; + --debug) + DEBUG=1 + shift + ;; + --quiet) + QUIET=1 + shift + ;; + -h | --help) + show_help + exit 0 + ;; + *) + printf 'Неизвестная опция: %s\n' "$1" >&2 + show_help >&2 + exit 2 + ;; + esac + done +} + +deploy_require_root() { + if [ "$DEPLOY_MODE" -ne 1 ]; then + return 0 + fi + if [ "$(id -u)" -ne 0 ]; then + printf 'ERROR: режим --deploy нужно запускать от root (sudo)\n' >&2 + exit 1 + fi +} + +# Основная функция логирования +log_message() { + local level="$1" + local message="$2" + local timestamp + timestamp="$(date '+%Y-%m-%d %H:%M:%S')" + + # Создаём лог-файл и директорию при необходимости + if [ ! -f "$LOG_FILE" ]; then + mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null + touch "$LOG_FILE" 2>/dev/null + fi + + if [ "$level" = "INFO" ]; then + echo "$timestamp INFO: $message" >> "$LOG_FILE" + elif [ "$level" = "DEBUG" ] && [ "$DEBUG" -eq 1 ]; then + echo "$timestamp DEBUG: $message" >> "$LOG_FILE" + elif [ "$level" = "ERROR" ]; then + echo "$timestamp ERROR: $message" >> "$LOG_FILE" + fi +} + +# Всегда в лог; в stderr — если не --quiet (stdout не трогаем: его используют $(find_*)) +log_action() { + log_message "INFO" "$1" + if [ "$QUIET" -eq 0 ]; then + printf '[update_ssh_monitor] %s\n' "$1" >&2 + fi +} + +get_git_short_rev() { + local repo_dir="$1" + git -C "$repo_dir" rev-parse --short HEAD 2>/dev/null || printf 'unknown' +} + +# Remote для fetch/pull: всегда kalinamall с URL из REPO_URL (SAC передаёт REPO_URL при SSH-обновлении). +resolve_git_remote_name() { + local repo="$1" + local name=kalinamall + if git -C "$repo" remote get-url "$name" &>/dev/null; then + git -C "$repo" remote set-url "$name" "$REPO_URL" >> "$LOG_FILE" 2>&1 + else + git -C "$repo" remote add "$name" "$REPO_URL" >> "$LOG_FILE" 2>&1 \ + || git -C "$repo" remote set-url "$name" "$REPO_URL" >> "$LOG_FILE" 2>&1 + fi + printf '%s' "$name" +} + +load_git_verify_settings() { + local v + if [ -f "$CONFIG_FILE" ]; then + if [ -z "${GIT_REF:-}" ]; then + v=$(config_conf_value GIT_REF "$CONFIG_FILE" 2>/dev/null || true) + [ -n "$v" ] && GIT_REF="$v" + fi + if [ -z "${GIT_VERIFY_MODE:-}" ] || [ "$GIT_VERIFY_MODE" = "off" ]; then + v=$(config_conf_value GIT_VERIFY_MODE "$CONFIG_FILE" 2>/dev/null || true) + [ -n "$v" ] && GIT_VERIFY_MODE="$v" + fi + if [ "${GIT_ALLOW_RESET:-0}" = "0" ]; then + v=$(config_conf_value GIT_ALLOW_RESET "$CONFIG_FILE" 2>/dev/null || true) + [ -n "$v" ] && GIT_ALLOW_RESET="$v" + fi + fi + GIT_VERIFY_MODE="$(printf '%s' "${GIT_VERIFY_MODE:-off}" | tr '[:upper:]' '[:lower:]')" + case "$GIT_VERIFY_MODE" in + off | tag | commit) ;; + *) + log_action "WARN: GIT_VERIFY_MODE=$GIT_VERIFY_MODE неизвестен — off" + GIT_VERIFY_MODE=off + ;; + esac + case "${GIT_ALLOW_RESET:-0}" in + 1 | yes | true | on) GIT_ALLOW_RESET=1 ;; + *) GIT_ALLOW_RESET=0 ;; + esac + case "${RELEASE_MANIFEST_STRICT:-0}" in + 1 | yes | true | on) MANIFEST_STRICT=1 ;; + *) MANIFEST_STRICT=0 ;; + esac +} + +verify_git_ref() { + local repo="$1" ref="$2" mode="$3" + local obj_type full + + [ "$mode" = "off" ] && return 0 + [ -n "$ref" ] || { + log_action "ERROR: GIT_VERIFY_MODE=$mode требует GIT_REF" + return 1 + } + + case "$mode" in + tag) + if ! git -C "$repo" rev-parse "refs/tags/$ref" >/dev/null 2>&1; then + log_action "ERROR: GIT_REF=$ref не найден как тег в клоне" + return 1 + fi + obj_type=$(git -C "$repo" cat-file -t "refs/tags/$ref" 2>/dev/null || true) + if [ "$obj_type" != "tag" ] && [ "$obj_type" != "commit" ]; then + log_action "ERROR: GIT_REF=$ref: неожиданный тип объекта ($obj_type)" + return 1 + fi + log_action "git verify: тег $ref OK (type=$obj_type)" + ;; + commit) + full=$(git -C "$repo" rev-parse "$ref^{commit}" 2>/dev/null || true) + if [ -z "$full" ] || [ "${#full}" -ne 40 ]; then + log_action "ERROR: GIT_REF=$ref не является полным commit SHA" + return 1 + fi + log_action "git verify: commit ${full:0:12}… OK" + ;; + esac + return 0 +} + +find_release_manifest_path() { + local repo="$1" version="$2" + local p="$repo/release/manifest-${version}.json" + if [ -f "$p" ]; then + printf '%s\n' "$p" + return 0 + fi + return 1 +} + +read_repo_release_version() { + local repo="$1" ver + ver=$(read_ssh_monitor_version "$repo/ssh-monitor") + if [ -z "$ver" ] && [ -f "$repo/version.txt" ]; then + ver=$(tr -d '\r\n' <"$repo/version.txt") + fi + printf '%s' "$ver" +} + +verify_release_manifest() { + local repo="$1" version manifest_path rc + + MANIFEST_VERIFIED=0 + version=$(read_repo_release_version "$repo") + if [ -z "$version" ]; then + SUMMARY_MANIFEST="версия не определена" + log_action "WARN: не удалось определить версию релиза для manifest" + [ "$MANIFEST_STRICT" -eq 1 ] && return 1 + return 0 + fi + + manifest_path=$(find_release_manifest_path "$repo" "$version" 2>/dev/null || true) + if [ -z "$manifest_path" ]; then + SUMMARY_MANIFEST="manifest-${version}.json не найден (skip)" + log_action "WARN: release/manifest-${version}.json не найден — проверка SHA пропущена" + if [ "$MANIFEST_STRICT" -eq 1 ]; then + log_action "ERROR: RELEASE_MANIFEST_STRICT=1 — manifest обязателен" + return 1 + fi + return 0 + fi + + log_action "manifest: проверка $manifest_path" + if ! MANIFEST_PATH="$manifest_path" REPO_DIR="$repo" python3 <<'PY' >>"$LOG_FILE" 2>&1 +import hashlib +import json +import os +import subprocess +import sys + +manifest_path = os.environ["MANIFEST_PATH"] +repo = os.environ["REPO_DIR"] + +with open(manifest_path, encoding="utf-8") as f: + manifest = json.load(f) + +expected_commit = (manifest.get("git_commit") or "").strip() +if expected_commit: + actual = subprocess.check_output(["git", "-C", repo, "rev-parse", "HEAD"], text=True).strip() + if actual != expected_commit: + print(f"commit mismatch: HEAD={actual[:12]} manifest={expected_commit[:12]}", file=sys.stderr) + sys.exit(2) + +files = manifest.get("files") or {} +for rel, spec in files.items(): + path = os.path.join(repo, rel) + if not os.path.isfile(path): + print(f"missing file: {rel}", file=sys.stderr) + sys.exit(3) + want = str(spec).removeprefix("sha256:") + h = hashlib.sha256() + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(1024 * 1024), b""): + h.update(chunk) + got = h.hexdigest() + if got != want: + print(f"sha256 mismatch: {rel}", file=sys.stderr) + sys.exit(4) + +print("OK") +PY + then + SUMMARY_MANIFEST="ОШИБКА verify" + log_action "ERROR: manifest verify failed (см. $LOG_FILE)" + return 1 + fi + + MANIFEST_VERIFIED=1 + SUMMARY_MANIFEST="OK ($version)" + log_action "manifest: verify OK ($version)" + return 0 +} + +# fetch + checkout GIT_REF или fast-forward по GIT_BRANCH; без reset --hard (кроме GIT_ALLOW_RESET=1). +sync_git_repository() { + local repo="$1" branch="${2:-main}" + local remote upstream git_before git_after ref="${GIT_REF:-}" + + remote="$(resolve_git_remote_name "$repo")" + upstream="${remote}/${branch}" + + if [ -f "$repo/.git/MERGE_HEAD" ]; then + log_action "git: незавершённый merge — abort" + git -C "$repo" merge --abort >>"$LOG_FILE" 2>&1 || true + fi + + git_before="$(get_git_short_rev "$repo")" + + if [ -n "$ref" ]; then + log_action "git: fetch $remote (ref=$ref, verify=$GIT_VERIFY_MODE) → $REPO_URL" + if ! git -C "$repo" fetch --prune "$remote" \ + "+refs/heads/*:refs/remotes/${remote}/*" \ + "+refs/tags/*:refs/tags/*" >>"$LOG_FILE" 2>&1; then + SUMMARY_GIT="fetch FAILED ($git_before)" + log_action "ERROR: git fetch failed (см. $LOG_FILE)" + return 1 + fi + if ! verify_git_ref "$repo" "$ref" "$GIT_VERIFY_MODE"; then + SUMMARY_GIT="verify FAILED ($ref)" + return 1 + fi + if ! git -C "$repo" checkout -f "$ref" >>"$LOG_FILE" 2>&1; then + SUMMARY_GIT="checkout FAILED ($ref)" + log_action "ERROR: git checkout $ref failed (см. $LOG_FILE)" + return 1 + fi + git_after="$(get_git_short_rev "$repo")" + SUMMARY_GIT="checkout $ref ok, $git_before → $git_after" + log_action "git: checkout $ref OK ($git_before → $git_after)" + return 0 + fi + + log_action "git: fetch --prune $remote $branch (было $git_before) → $REPO_URL" + if ! git -C "$repo" fetch --prune "$remote" "$branch" >>"$LOG_FILE" 2>&1; then + SUMMARY_GIT="fetch FAILED ($git_before)" + log_action "ERROR: git fetch failed (см. $LOG_FILE)" + return 1 + fi + + if git -C "$repo" pull --ff-only "$remote" "$branch" >>"$LOG_FILE" 2>&1; then + git_after="$(get_git_short_rev "$repo")" + SUMMARY_GIT="pull ok, $git_before → $git_after" + log_action "git: fast-forward OK ($git_before → $git_after)" + return 0 + fi + + if [ "$GIT_ALLOW_RESET" -eq 1 ]; then + log_action "WARN: fast-forward невозможен — GIT_ALLOW_RESET=1, reset --hard $upstream" + if ! git -C "$repo" reset --hard "$upstream" >>"$LOG_FILE" 2>&1; then + SUMMARY_GIT="reset FAILED ($git_before)" + log_action "ERROR: git reset --hard $upstream failed (см. $LOG_FILE)" + return 1 + fi + git_after="$(get_git_short_rev "$repo")" + SUMMARY_GIT="reset ok, $git_before → $git_after" + log_action "git reset --hard: $git_before → $git_after" + return 0 + fi + + SUMMARY_GIT="diverged ($git_before)" + log_action "ERROR: git: история разошлась (ff-only невозможен). Ручной clone или GIT_ALLOW_RESET=1" + echo "ERROR: git pull --ff-only failed; история разошлась. Исправьте клон в $repo или задайте GIT_ALLOW_RESET=1 (риск)." >&2 + return 1 +} + +read_ssh_monitor_version() { + local f="$1" + local line + [ -f "$f" ] || return 0 + line=$(grep -E '^[[:space:]]*SSH_MONITOR_VERSION=' "$f" 2>/dev/null | head -1) || return 0 + line=${line#SSH_MONITOR_VERSION=} + line=${line#\"} + line=${line%\"} + line=${line#\'} + line=${line%\'} + printf '%s' "$line" +} + +# Версия SAC-модуля совпадает с SSH_MONITOR_VERSION основного скрипта в том же каталоге. +read_sac_client_version() { + local sac_file="$1" + local dir="${sac_file%/*}" + read_ssh_monitor_version "$dir/ssh-monitor" +} + +warn_version_mismatch() { + local repo_ssh="$1" + local repo_ver vt + repo_ver="$(read_ssh_monitor_version "$repo_ssh")" + [ -f "$repo_ssh" ] || return 0 + if [ -f "${repo_ssh%/*}/version.txt" ]; then + vt="$(tr -d '\r\n' < "${repo_ssh%/*}/version.txt")" + if [ -n "$repo_ver" ] && [ -n "$vt" ] && [ "$repo_ver" != "$vt" ]; then + log_action "WARN: SSH_MONITOR_VERSION=$repo_ver != version.txt=$vt" + fi + fi +} + +file_sha256() { + sha256sum "$1" 2>/dev/null | awk '{print $1}' || true +} + +log_update_summary() { + log_action "===== Итог обновления =====" + log_action "ssh-monitor: $SUMMARY_SCRIPT ($SUMMARY_SCRIPT_REASON)" + log_action "sac-client.sh: $SUMMARY_SAC_CLIENT ($SUMMARY_SAC_REASON)" + log_action "конфиг: $SUMMARY_CONFIG" + log_action "git: $SUMMARY_GIT" + log_action "updater ($DEPLOY_INSTALL_PATH): $SUMMARY_UPDATER" + log_action "watchdog: $SUMMARY_WATCHDOG" + log_action "security hardening: $SUMMARY_HARDENING" + log_action "manifest: $SUMMARY_MANIFEST" + log_action "перезапуск ssh-monitor.service: $SUMMARY_SERVICE_RESTART" + log_action "полный лог: $LOG_FILE" + log_action "===========================" +} + +# Функция для логирования шагов с комментариями (работает только при DEBUG=1) +debug_step() { + if [ "$DEBUG" -eq 1 ]; then + log_message "DEBUG" "# $1" + fi +} + +# Функция для логирования только объявленных в скрипте переменных +log_script_vars() { + if [ "$DEBUG" -eq 1 ]; then + log_message "DEBUG" "=== Значения переменных скрипта ===" + for var_name in "${SCRIPT_VARS[@]}"; do + local var_value="${!var_name}" + log_message "DEBUG" "$var_name='$var_value'" + done + log_message "DEBUG" "=== Конец списка переменных ===" + fi +} + +# === КРИТИЧЕСКАЯ ФУНКЦИЯ: Проверка и установка ipset === +ensure_ipset_installed() { + debug_step "Проверяем наличие ipset в системе" + + # Проверяем, установлен ли ipset + if ! command -v ipset &>/dev/null; then + log_message "INFO" "ipset not found. Attempting silent installation..." + debug_step "ipset не обнаружен, начинаем установку в silent режиме" + + # Определяем пакетный менеджер и устанавливаем ipset + if command -v apt-get &>/dev/null || command -v apt &>/dev/null; then + debug_step "Обнаружен Debian/Ubuntu, используем apt" + log_message "INFO" "Detected apt package manager, installing ipset..." + + # Тихая установка через apt + if apt-get update -qq >/dev/null 2>&1 && apt-get install -y -qq ipset >/dev/null 2>&1; then + log_message "INFO" "ipset installed successfully via apt" + debug_step "ipset успешно установлен через apt" + else + log_message "ERROR" "Failed to install ipset via apt" + log_message "ERROR" "Работа обновленного скрипта невозможна без установленного ipset" + debug_step "КРИТИЧЕСКАЯ ОШИБКА: Не удалось установить ipset через apt" + return 1 + fi + + elif command -v yum &>/dev/null; then + debug_step "Обнаружен RHEL/CentOS, используем yum" + log_message "INFO" "Detected yum package manager, installing ipset..." + + if yum install -y -q ipset >/dev/null 2>&1; then + log_message "INFO" "ipset installed successfully via yum" + debug_step "ipset успешно установлен через yum" + else + log_message "ERROR" "Failed to install ipset via yum" + log_message "ERROR" "Работа обновленного скрипта невозможна без установленного ipset" + debug_step "КРИТИЧЕСКАЯ ОШИБКА: Не удалось установить ipset через yum" + return 1 + fi + + elif command -v dnf &>/dev/null; then + debug_step "Обнаружен Fedora/RHEL 8+, используем dnf" + log_message "INFO" "Detected dnf package manager, installing ipset..." + + if dnf install -y -q ipset >/dev/null 2>&1; then + log_message "INFO" "ipset installed successfully via dnf" + debug_step "ipset успешно установлен через dnf" + else + log_message "ERROR" "Failed to install ipset via dnf" + log_message "ERROR" "Работа обновленного скрипта невозможна без установленного ipset" + debug_step "КРИТИЧЕСКАЯ ОШИБКА: Не удалось установить ipset через dnf" + return 1 + fi + + elif command -v pacman &>/dev/null; then + debug_step "Обнаружен Arch Linux, используем pacman" + log_message "INFO" "Detected pacman package manager, installing ipset..." + + if pacman -S --noconfirm --quiet ipset >/dev/null 2>&1; then + log_message "INFO" "ipset installed successfully via pacman" + debug_step "ipset успешно установлен через pacman" + else + log_message "ERROR" "Failed to install ipset via pacman" + log_message "ERROR" "Работа обновленного скрипта невозможна без установленного ipset" + debug_step "КРИТИЧЕСКАЯ ОШИБКА: Не удалось установить ipset через pacman" + return 1 + fi + + elif command -v zypper &>/dev/null; then + debug_step "Обнаружен openSUSE, используем zypper" + log_message "INFO" "Detected zypper package manager, installing ipset..." + + if zypper --quiet --non-interactive install ipset >/dev/null 2>&1; then + log_message "INFO" "ipset installed successfully via zypper" + debug_step "ipset успешно установлен через zypper" + else + log_message "ERROR" "Failed to install ipset via zypper" + log_message "ERROR" "Работа обновленного скрипта невозможна без установленного ipset" + debug_step "КРИТИЧЕСКАЯ ОШИБКА: Не удалось установить ipset через zypper" + return 1 + fi + + else + log_message "ERROR" "Unsupported package manager. Cannot install ipset" + log_message "ERROR" "Работа обновленного скрипта невозможна без установленного ipset" + debug_step "КРИТИЧЕСКАЯ ОШИБКА: Пакетный менеджер не распознан" + return 1 + fi + + # Проверяем, что ipset действительно установился + if ! command -v ipset &>/dev/null; then + log_message "ERROR" "ipset installation verification failed" + log_message "ERROR" "Работа обновленного скрипта невозможна без установленного ipset" + debug_step "КРИТИЧЕСКАЯ ОШИБКА: ipset не обнаружен после установки" + return 1 + fi + + else + debug_step "ipset уже установлен в системе" + log_message "INFO" "ipset is already installed" + + # Проверяем версию ipset (информационно) + if [ "$DEBUG" -eq 1 ]; then + local ipset_version=$(ipset --version 2>&1) + log_message "DEBUG" "ipset version: $ipset_version" + fi + fi + + log_message "INFO" "ipset check/installation completed successfully" + return 0 +} + +# === Гарантированное создание директорий === +ensure_directories() { + debug_step "Проверяем и создаём необходимые директории" + + # Создаём UPDATE_DIR если не существует + if [ ! -d "$UPDATE_DIR" ]; then + log_message "INFO" "Creating directory: $UPDATE_DIR" + debug_step "Директория $UPDATE_DIR не существует, создаём" + if mkdir -p "$UPDATE_DIR" 2>/dev/null; then + log_message "INFO" "Directory created successfully" + else + log_message "INFO" "ERROR: Cannot create directory $UPDATE_DIR" + return 1 + fi + else + debug_step "Директория $UPDATE_DIR уже существует" + fi + + # Проверяем права на запись + if [ ! -w "$UPDATE_DIR" ]; then + log_message "INFO" "WARNING: Directory $UPDATE_DIR is not writable" + debug_step "ПРЕДУПРЕЖДЕНИЕ: Нет прав на запись в $UPDATE_DIR" + return 1 + fi + + return 0 +} + +# Путь к sac-client.sh в клоне репозитория (корень git) +find_sac_client_remote() { + local candidates=( + "$UPDATE_DIR/$SCRIPT_NAME/sac-client.sh" + ) + local p + for p in "${candidates[@]}"; do + if [ -f "$p" ]; then + printf '%s\n' "$p" + return 0 + fi + done + return 1 +} + +# Копирует sac-client.sh рядом с LOCAL_SCRIPT_PATH (всегда после git pull, по checksum). +# Устанавливает SAC_CLIENT_CHANGED=1 при фактическом обновлении файла. +sync_sac_client() { + SAC_CLIENT_CHANGED=0 + local remote local_dst local_sum remote_sum + remote=$(find_sac_client_remote) || { + SUMMARY_SAC_CLIENT="не найден в клоне" + SUMMARY_SAC_REASON="путь $UPDATE_DIR/$SCRIPT_NAME/sac-client.sh" + log_action "WARNING: sac-client.sh не найден в клоне git" + debug_step "sac-client.sh не найден в клоне репозитория" + return 0 + } + + local_dst="${LOCAL_SCRIPT_PATH%/*}/sac-client.sh" + local remote_ver local_ver + remote_ver="$(read_sac_client_version "$remote")" + local_ver="$(read_sac_client_version "$local_dst")" + log_action "sac-client: remote=$remote ver=${remote_ver:-?} local=$local_dst ver=${local_ver:-?}" + remote_sum=$(file_sha256 "$remote") + if [ -z "$remote_sum" ]; then + SUMMARY_SAC_CLIENT="ошибка" + SUMMARY_SAC_REASON="не удалось sha256 remote" + log_action "ERROR: не удалось посчитать sha256 для $remote" + return 1 + fi + + if [ "$DEPLOY_MODE" -ne 1 ] && [ -f "$local_dst" ]; then + local_sum=$(file_sha256 "$local_dst") + if [ "$local_sum" = "$remote_sum" ]; then + SUMMARY_SAC_CLIENT="без изменений" + SUMMARY_SAC_REASON="sha256 совпадает; ver=${remote_ver:-?} (${local_sum:0:16}…)" + log_action "sac-client.sh: checksum совпадает, копирование не требуется" + debug_step "sac-client.sh без изменений" + return 0 + fi + log_action "sac-client.sh: checksum отличается (local ${local_sum:0:16}… != remote ${remote_sum:0:16}…)" + elif [ ! -f "$local_dst" ]; then + log_action "sac-client.sh: локальный файл отсутствует, будет установлен" + debug_step "Локальный sac-client.sh отсутствует, будет установлен" + fi + + debug_step "Копируем sac-client.sh в $local_dst" + if ! cp "$remote" "$local_dst"; then + SUMMARY_SAC_CLIENT="ошибка копирования" + SUMMARY_SAC_REASON="cp $remote -> $local_dst" + log_action "ERROR: не удалось скопировать sac-client.sh" + return 1 + fi + chmod 644 "$local_dst" + SAC_CLIENT_CHANGED=1 + SUMMARY_SAC_CLIENT="обновлён" + SUMMARY_SAC_REASON="ver ${local_ver:-?} → ${remote_ver:-?}, sha256 ${remote_sum:0:16}…" + log_action "sac-client.sh обновлён → $local_dst" + if [ "$DEPLOY_MODE" -eq 1 ]; then + say_ok "sac-client.sh → $local_dst" + fi + return 0 +} + +find_ssh_monitor_perms_remote() { + local p="$UPDATE_DIR/$SCRIPT_NAME/ssh-monitor-perms.sh" + if [ -f "$p" ]; then + printf '%s\n' "$p" + return 0 + fi + return 1 +} + +sync_ssh_monitor_perms_lib() { + local remote local_dst remote_sum local_sum="" + remote=$(find_ssh_monitor_perms_remote) || { + log_action "WARNING: ssh-monitor-perms.sh не найден в клоне git" + return 0 + } + local_dst="${LOCAL_SCRIPT_PATH%/*}/ssh-monitor-perms.sh" + remote_sum=$(file_sha256 "$remote") + if [ -f "$local_dst" ]; then + local_sum=$(file_sha256 "$local_dst") + if [ "$local_sum" = "$remote_sum" ]; then + return 0 + fi + fi + if ! cp "$remote" "$local_dst"; then + log_action "ERROR: не удалось скопировать ssh-monitor-perms.sh" + return 1 + fi + chmod 644 "$local_dst" + log_action "ssh-monitor-perms.sh обновлён → $local_dst" + return 0 +} + +find_remote_ssh_monitor_path() { + local repo="$UPDATE_DIR/$SCRIPT_NAME" + local candidates=() git_path="" found="" p + + REMOTE_SCRIPT_PATH="$repo/ssh-monitor" + ALT_REMOTE_PATH="$repo/src/branch/main/ssh-monitor" + candidates=( + "$REMOTE_SCRIPT_PATH" + "$ALT_REMOTE_PATH" + ) + + for p in "${candidates[@]}"; do + if [ -f "$p" ]; then + log_action "ssh-monitor в клоне: $p" + printf '%s\n' "$p" + return 0 + fi + done + + if [ -d "$repo/.git" ]; then + if git -C "$repo" cat-file -e "HEAD:ssh-monitor" 2>/dev/null; then + if [ ! -f "$repo/ssh-monitor" ]; then + log_action "ssh-monitor есть в git HEAD, но отсутствует на диске — git checkout HEAD -- ssh-monitor" + git -C "$repo" checkout HEAD -- ssh-monitor >> "$LOG_FILE" 2>&1 || true + fi + if [ -f "$repo/ssh-monitor" ]; then + log_action "ssh-monitor восстановлен: $repo/ssh-monitor" + printf '%s\n' "$repo/ssh-monitor" + return 0 + fi + fi + git_path=$(git -C "$repo" ls-files 'ssh-monitor' 2>/dev/null | grep -v '^contrib/' | head -1) + if [ -z "$git_path" ]; then + git_path=$(git -C "$repo" ls-files 2>/dev/null | grep -E '/ssh-monitor$' | grep -v contrib | grep -v watchdog | head -1) + fi + if [ -n "$git_path" ] && [ -f "$repo/$git_path" ]; then + log_action "ssh-monitor в клоне (git ls-files): $repo/$git_path" + printf '%s\n' "$repo/$git_path" + return 0 + fi + fi + + found=$(find "$repo" -maxdepth 4 -type f -name 'ssh-monitor' ! -path '*/contrib/*' 2>/dev/null | head -1) + if [ -n "$found" ] && [ -f "$found" ]; then + log_action "ssh-monitor найден поиском: $found" + printf '%s\n' "$found" + return 0 + fi + + log_action "WARNING: ssh-monitor не найден в $repo" + if [ -d "$repo" ]; then + log_action "корень клона: $(ls -1 "$repo" 2>/dev/null | tr '\n' ' ')" + fi + return 1 +} + +install_ssh_monitor_from() { + local actual_script_path="$1" + local backup_path="" + if [ -f "$LOCAL_SCRIPT_PATH" ]; then + backup_path="${LOCAL_SCRIPT_PATH}.backup.$(date +%Y%m%d_%H%M%S)" + cp "$LOCAL_SCRIPT_PATH" "$backup_path" 2>/dev/null || true + log_action "резервная копия: $backup_path" + fi + if ! cp "$actual_script_path" "$LOCAL_SCRIPT_PATH"; then + log_action "ERROR: не удалось скопировать $actual_script_path → $LOCAL_SCRIPT_PATH" + return 1 + fi + chmod +x "$LOCAL_SCRIPT_PATH" + log_action "ssh-monitor установлен: $LOCAL_SCRIPT_PATH (версия $(read_ssh_monitor_version "$LOCAL_SCRIPT_PATH"))" + if [ "$DEPLOY_MODE" -eq 1 ]; then + say_ok "ssh-monitor → $LOCAL_SCRIPT_PATH" + fi + return 0 +} + +deploy_prepare_dirs() { + say_step "Каталоги" + mkdir -p "$UPDATE_DIR" /opt/scripts /var/log /var/lib/ssh-monitor 2>/dev/null || true + mkdir -p "$SAC_SPOOL_DIR_DEFAULT" 2>/dev/null || true + chmod 700 "$SAC_SPOOL_DIR_DEFAULT" 2>/dev/null || true + chown root:root "$SAC_SPOOL_DIR_DEFAULT" 2>/dev/null || true + say_ok "UPDATE_DIR=$UPDATE_DIR, spool=$SAC_SPOOL_DIR_DEFAULT" +} + +deploy_install_config() { + say_step "Конфигурация" + ensure_ssh_monitor_config_file || return 1 + sync_sac_config_keys || true + sync_required_config_keys || true + sync_heartbeat_interval_config || true + sync_server_ipv4_hint_config || true + sync_daily_report_enabled_config || true + apply_runtime_security_hardening +} + +deploy_install_systemd() { + local example="$UPDATE_DIR/$SCRIPT_NAME/ssh-monitor.service.example" + say_step "systemd" + if [ -f "$SYSTEMD_UNIT_PATH" ]; then + say_info "Уже существует: $SYSTEMD_UNIT_PATH" + elif [ ! -f "$example" ]; then + say_warn "Unit example не найден: $example" + else + cp "$example" "$SYSTEMD_UNIT_PATH" + if command -v systemctl &>/dev/null; then + systemctl daemon-reload 2>/dev/null || true + systemctl enable ssh-monitor.service 2>/dev/null || true + say_ok "Установлен $SYSTEMD_UNIT_PATH (enable ssh-monitor.service)" + else + say_ok "Скопирован $SYSTEMD_UNIT_PATH (systemctl не найден)" + fi + fi + ensure_watchdog_installed || true +} + +find_watchdog_remote() { + local p="$UPDATE_DIR/$SCRIPT_NAME/ssh-monitor-watchdog" + if [ -f "$p" ]; then + printf '%s\n' "$p" + return 0 + fi + return 1 +} + +sync_watchdog_script() { + WATCHDOG_CHANGED=0 + local remote local_sum remote_sum + remote=$(find_watchdog_remote) || { + SUMMARY_WATCHDOG="скрипт не найден в клоне" + log_action "WARNING: ssh-monitor-watchdog не найден в клоне git" + return 0 + } + + remote_sum=$(file_sha256 "$remote") + if [ -z "$remote_sum" ]; then + SUMMARY_WATCHDOG="ошибка sha256 remote" + return 1 + fi + + if [ -f "$LOCAL_WATCHDOG_PATH" ]; then + local_sum=$(file_sha256 "$LOCAL_WATCHDOG_PATH") + if [ "$local_sum" = "$remote_sum" ]; then + log_action "ssh-monitor-watchdog: checksum совпадает, копирование не требуется" + return 0 + fi + log_action "ssh-monitor-watchdog: обновление (sha ${local_sum:0:16}… → ${remote_sum:0:16}…)" + else + log_action "ssh-monitor-watchdog: локальный файл отсутствует, будет установлен" + fi + + if ! cp "$remote" "$LOCAL_WATCHDOG_PATH"; then + SUMMARY_WATCHDOG="ошибка копирования" + log_action "ERROR: не удалось скопировать ssh-monitor-watchdog → $LOCAL_WATCHDOG_PATH" + return 1 + fi + chmod 750 "$LOCAL_WATCHDOG_PATH" + WATCHDOG_CHANGED=1 + SUMMARY_WATCHDOG="скрипт обновлён" + if [ "$DEPLOY_MODE" -eq 1 ]; then + say_ok "ssh-monitor-watchdog → $LOCAL_WATCHDOG_PATH" + fi + return 0 +} + +install_watchdog_systemd_units() { + local svc_example timer_example units_changed=0 + svc_example="$UPDATE_DIR/$SCRIPT_NAME/ssh-monitor-watchdog.service.example" + timer_example="$UPDATE_DIR/$SCRIPT_NAME/ssh-monitor-watchdog.timer.example" + + if [ ! -f "$svc_example" ] || [ ! -f "$timer_example" ]; then + log_action "WARNING: watchdog unit examples не найдены в клоне" + return 0 + fi + + if [ ! -f "$WATCHDOG_SERVICE_PATH" ] \ + || ! cmp -s "$svc_example" "$WATCHDOG_SERVICE_PATH" 2>/dev/null; then + cp "$svc_example" "$WATCHDOG_SERVICE_PATH" + units_changed=1 + log_action "watchdog: установлен/обновлён $WATCHDOG_SERVICE_PATH" + fi + + if [ ! -f "$WATCHDOG_TIMER_PATH" ] \ + || ! cmp -s "$timer_example" "$WATCHDOG_TIMER_PATH" 2>/dev/null; then + cp "$timer_example" "$WATCHDOG_TIMER_PATH" + units_changed=1 + log_action "watchdog: установлен/обновлён $WATCHDOG_TIMER_PATH" + fi + + if [ "$units_changed" -eq 1 ]; then + WATCHDOG_CHANGED=1 + fi + + if ! command -v systemctl &>/dev/null; then + log_action "watchdog: systemctl не найден, units только скопированы" + return 0 + fi + + systemctl daemon-reload 2>/dev/null || true + + if systemctl enable ssh-monitor-watchdog.timer 2>/dev/null; then + log_action "watchdog: enable ssh-monitor-watchdog.timer" + fi + + if systemctl start ssh-monitor-watchdog.timer 2>/dev/null; then + log_action "watchdog: start ssh-monitor-watchdog.timer" + if [ -z "$SUMMARY_WATCHDOG" ] || [ "$SUMMARY_WATCHDOG" = "—" ]; then + SUMMARY_WATCHDOG="timer активен" + elif [ "${WATCHDOG_CHANGED:-0}" -eq 1 ]; then + SUMMARY_WATCHDOG="${SUMMARY_WATCHDOG}; timer активен" + fi + else + log_action "WARNING: systemctl start ssh-monitor-watchdog.timer не выполнен" + fi + + if [ "$DEPLOY_MODE" -eq 1 ] && [ "${WATCHDOG_CHANGED:-0}" -eq 1 ]; then + say_ok "watchdog: units + timer (ssh-monitor-watchdog.timer)" + fi + return 0 +} + +ensure_watchdog_installed() { + sync_watchdog_script || return 1 + install_watchdog_systemd_units || return 1 + if [ -z "$SUMMARY_WATCHDOG" ] || [ "$SUMMARY_WATCHDOG" = "—" ]; then + if [ -f "$LOCAL_WATCHDOG_PATH" ] && systemctl is-enabled ssh-monitor-watchdog.timer &>/dev/null; then + SUMMARY_WATCHDOG="без изменений (timer enabled)" + elif [ -f "$LOCAL_WATCHDOG_PATH" ]; then + SUMMARY_WATCHDOG="скрипт на месте, timer настроен" + else + SUMMARY_WATCHDOG="не установлен" + fi + fi + return 0 +} + +deploy_install_self() { + local self_path + self_path="$(readlink -f "$0" 2>/dev/null || realpath "$0" 2>/dev/null || printf '%s' "$0")" + say_step "Скрипт автообновления" + if [ "$self_path" = "$DEPLOY_INSTALL_PATH" ]; then + say_info "Уже запущен из $DEPLOY_INSTALL_PATH" + return 0 + fi + mkdir -p "$(dirname "$DEPLOY_INSTALL_PATH")" + cp "$self_path" "$DEPLOY_INSTALL_PATH" + chmod 750 "$DEPLOY_INSTALL_PATH" + say_ok "update_ssh_monitor.sh → $DEPLOY_INSTALL_PATH" +} + +print_config_variables_help() { + say_step "Заполните в $CONFIG_FILE" + cat <<'EOF' + Обязательно хотя бы один канал уведомлений ИЛИ SAC: + TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID + — или MAIL_SMTP_HOST, MAIL_FROM, MAIL_TO + + Security Alert Center: + UseSAC=off | dual | exclusive | fallback + Пилот 2.1 (только SAC): UseSAC=exclusive + SAC_URL=https://sac.kalinamall.ru + SAC_API_KEY=<ключ из SAC sac-api.env / bootstrap> + + Проверка после правок: + /usr/local/bin/ssh-monitor --check-config + /usr/local/bin/ssh-monitor --check-sac + systemctl restart ssh-monitor.service + systemctl status ssh-monitor.service + systemctl status ssh-monitor-watchdog.timer + systemctl list-timers | grep ssh-monitor-watchdog +EOF +} + +print_deploy_summary() { + say_step "Итог установки" + say_info "Монитор: $LOCAL_SCRIPT_PATH" + say_info "SAC client: ${LOCAL_SCRIPT_PATH%/*}/sac-client.sh" + say_info "Конфиг: $CONFIG_FILE" + say_info "Git-клон: $UPDATE_DIR/$SCRIPT_NAME" + say_info "Updater: $DEPLOY_INSTALL_PATH" + say_info "Лог updater: $LOG_FILE" +} + +deploy_post_install() { + deploy_prepare_dirs + deploy_install_config + deploy_install_systemd + deploy_install_self + print_deploy_summary + print_config_variables_help +} + +# Копирует update_ssh_monitor.sh из git-клона в /opt/scripts/ и при необходимости re-exec. +sync_installed_updater_from_clone() { + local remote="$UPDATE_DIR/$SCRIPT_NAME/update_ssh_monitor.sh" + local self installed_path remote_sum installed_sum="" + local repo="$UPDATE_DIR/$SCRIPT_NAME" ver manifest_path="" + + if [ ! -f "$remote" ]; then + SUMMARY_UPDATER="не найден в клоне" + log_action "updater: в клоне нет $remote" + return 0 + fi + + ver=$(read_repo_release_version "$repo") + if [ -n "$ver" ]; then + manifest_path=$(find_release_manifest_path "$repo" "$ver" 2>/dev/null || true) + if [ -n "$manifest_path" ] && [ "${MANIFEST_VERIFIED:-0}" != "1" ]; then + SUMMARY_UPDATER="manifest не verified" + log_action "ERROR: updater: $manifest_path не verified — копирование пропущено" + return 1 + fi + fi + + self="$(readlink -f "$0" 2>/dev/null || realpath "$0" 2>/dev/null || printf '%s' "$0")" + installed_path="$(readlink -f "$DEPLOY_INSTALL_PATH" 2>/dev/null || realpath "$DEPLOY_INSTALL_PATH" 2>/dev/null || printf '%s' "$DEPLOY_INSTALL_PATH")" + remote_sum=$(file_sha256 "$remote") + if [ -f "$DEPLOY_INSTALL_PATH" ]; then + installed_sum=$(file_sha256 "$DEPLOY_INSTALL_PATH") + fi + + if [ "$remote_sum" = "$installed_sum" ]; then + SUMMARY_UPDATER="актуален (sha ${remote_sum:0:16}…)" + log_action "updater: $DEPLOY_INSTALL_PATH совпадает с git (commit $(get_git_short_rev "$UPDATE_DIR/$SCRIPT_NAME"))" + return 0 + fi + + mkdir -p "$(dirname "$DEPLOY_INSTALL_PATH")" + if ! cp "$remote" "$DEPLOY_INSTALL_PATH"; then + SUMMARY_UPDATER="ошибка копирования" + log_action "ERROR: не удалось обновить $DEPLOY_INSTALL_PATH из git" + return 1 + fi + chmod 750 "$DEPLOY_INSTALL_PATH" + SUMMARY_UPDATER="обновлён из git (${installed_sum:0:8}…→${remote_sum:0:8}…)" + log_action "updater: записан $DEPLOY_INSTALL_PATH из клона (новый sha ${remote_sum:0:16}…)" + + if [ "$self" = "$installed_path" ] && [ "${UPDATER_REEXEC_GUARD:-0}" != "1" ]; then + log_action "updater: перезапуск с новой версией (расширенный лог)…" + UPDATER_REEXEC_GUARD=1 exec /bin/bash "$DEPLOY_INSTALL_PATH" "$@" + fi + + if [ "$self" != "$installed_path" ]; then + log_action "updater: сейчас запущен $self — в cron используйте $DEPLOY_INSTALL_PATH" + fi + return 0 +} + +restart_ssh_monitor_service() { + debug_step "Перезапускаем сервис ssh-monitor.service" + if command -v systemctl &>/dev/null; then + if systemctl restart ssh-monitor.service 2>/dev/null; then + SUMMARY_SERVICE_RESTART="выполнен" + log_action "ssh-monitor.service перезапущен" + debug_step "Сервис успешно перезапущен" + local i + for i in $(seq 1 30); do + if systemctl is-active --quiet ssh-monitor.service 2>/dev/null; then + sleep 2 + break + fi + sleep 1 + done + else + SUMMARY_SERVICE_RESTART="не удался" + log_action "WARNING: systemctl restart ssh-monitor.service не выполнен" + debug_step "ПРЕДУПРЕЖДЕНИЕ: Не удалось перезапустить сервис" + fi + else + SUMMARY_SERVICE_RESTART="пропущен (нет systemctl)" + log_action "systemctl не найден, перезапуск сервиса пропущен" + debug_step "systemctl не найден, сервис не перезапущен" + fi +} + +# Функция для обновления скрипта (принимает "$@" для re-exec updater) +update_script() { + local git_repo="$UPDATE_DIR/$SCRIPT_NAME" + local git_before="" git_after="" pull_ok=1 + local actual_script_path="" script_changed=0 + local local_ver="" remote_ver="" local_sum="" remote_sum="" + + SUMMARY_SCRIPT="—" + SUMMARY_SCRIPT_REASON="—" + SUMMARY_SAC_CLIENT="—" + SUMMARY_SAC_REASON="—" + SUMMARY_CONFIG="—" + SUMMARY_GIT="—" + SUMMARY_HARDENING="—" + SUMMARY_MANIFEST="—" + SUMMARY_SERVICE_RESTART="не требовался" + + load_git_verify_settings + load_ssh_monitor_perms_lib || true + log_action "Старт обновления (deploy=$DEPLOY_MODE, debug=$DEBUG, git_ref=${GIT_REF:-$GIT_BRANCH})" + debug_step "Запуск функции обновления скрипта" + + debug_step "Гарантируем наличие директории для работы" + if ! ensure_directories; then + SUMMARY_SCRIPT="ошибка" + SUMMARY_SCRIPT_REASON="нет каталога $UPDATE_DIR или нет прав на запись" + log_update_summary + return 1 + fi + + debug_step "Переходим в директорию для клона репозитория: $UPDATE_DIR" + if ! cd "$UPDATE_DIR" 2>/dev/null; then + SUMMARY_SCRIPT="ошибка" + SUMMARY_SCRIPT_REASON="cannot cd $UPDATE_DIR" + log_action "ERROR: cannot cd $UPDATE_DIR" + log_update_summary + return 1 + fi + + debug_step "Проверяем наличие репозитория и обновляем/клонируем" + if [ -d "$SCRIPT_NAME" ]; then + if sync_git_repository "$git_repo" "$GIT_BRANCH"; then + pull_ok=1 + else + pull_ok=0 + log_action "WARNING: git sync не удался — remote в клоне может быть устаревшим" + fi + else + local clone_ref="${GIT_REF:-$GIT_BRANCH}" + log_action "git: clone -b $clone_ref $REPO_URL → $UPDATE_DIR/$SCRIPT_NAME" + debug_step "Выполняем git clone -b $clone_ref $REPO_URL $SCRIPT_NAME" + if ! git clone -b "$clone_ref" "$REPO_URL" "$SCRIPT_NAME" >>"$LOG_FILE" 2>&1; then + SUMMARY_GIT="clone FAILED" + SUMMARY_SCRIPT="ошибка" + SUMMARY_SCRIPT_REASON="git clone failed" + log_action "ERROR: git clone failed (см. $LOG_FILE)" + log_update_summary + return 1 + fi + if [ -n "${GIT_REF:-}" ] && ! verify_git_ref "$git_repo" "$GIT_REF" "$GIT_VERIFY_MODE"; then + SUMMARY_GIT="verify FAILED ($GIT_REF)" + SUMMARY_SCRIPT="ошибка" + SUMMARY_SCRIPT_REASON="GIT_REF verify failed" + log_update_summary + return 1 + fi + git_after="$(get_git_short_rev "$git_repo")" + SUMMARY_GIT="clone ok, commit $git_after" + log_action "git clone: успех (commit $git_after)" + fi + + if ! verify_release_manifest "$git_repo"; then + SUMMARY_SCRIPT="ошибка" + SUMMARY_SCRIPT_REASON="manifest verify failed" + log_update_summary + return 1 + fi + + sync_installed_updater_from_clone "$@" + + actual_script_path="$(find_remote_ssh_monitor_path 2>/dev/null || true)" + actual_script_path="${actual_script_path//$'\n'/}" + actual_script_path="${actual_script_path//$'\r'/}" + debug_step "Путь к ssh-monitor в клоне: ${actual_script_path:-не найден}" + if [ -n "$actual_script_path" ] && [ -f "$actual_script_path" ]; then + warn_version_mismatch "$actual_script_path" + fi + + SAC_CLIENT_CHANGED=0 + if ! sync_sac_client; then + log_update_summary + return 1 + fi + sync_ssh_monitor_perms_lib || true + + WATCHDOG_CHANGED=0 + if ! ensure_watchdog_installed; then + log_action "WARNING: установка watchdog завершилась с ошибкой" + fi + + CONFIG_CHANGED=0 + if ! ensure_ssh_monitor_config_file; then + log_update_summary + return 1 + fi + if sync_sac_config_keys; then + SUMMARY_CONFIG="SAC-ключи дополнены, UseSAC=fallback" + else + SUMMARY_CONFIG="SAC без изменений" + fi + sync_required_config_keys && SUMMARY_CONFIG="${SUMMARY_CONFIG}; security keys" + sync_heartbeat_interval_config || true + sync_server_ipv4_hint_config || true + sync_daily_report_enabled_config || true + harden_ssh_monitor_config_perms + if [ "${CONFIG_CHANGED:-0}" -eq 1 ] && [ "$SUMMARY_CONFIG" = "SAC без изменений" ]; then + SUMMARY_CONFIG="heartbeat/watchdog выровнены" + elif [ "${CONFIG_CHANGED:-0}" -eq 1 ] && [ "$SUMMARY_CONFIG" != "SAC без изменений" ]; then + SUMMARY_CONFIG="${SUMMARY_CONFIG}; heartbeat при необходимости" + fi + + if [ -n "$actual_script_path" ] && [ -f "$actual_script_path" ]; then + if [ "$DEPLOY_MODE" -eq 1 ]; then + log_action "режим --deploy: установка ssh-monitor → $LOCAL_SCRIPT_PATH" + if install_ssh_monitor_from "$actual_script_path"; then + script_changed=1 + SUMMARY_SCRIPT="установлен (--deploy)" + SUMMARY_SCRIPT_REASON="$(read_ssh_monitor_version "$LOCAL_SCRIPT_PATH")" + else + SUMMARY_SCRIPT="ошибка установки" + log_update_summary + return 1 + fi + else + if [ ! -f "$LOCAL_SCRIPT_PATH" ]; then + LOCAL_CHECKSUM="" + local_ver="(нет файла)" + else + LOCAL_CHECKSUM=$(file_sha256 "$LOCAL_SCRIPT_PATH") + local_ver="$(read_ssh_monitor_version "$LOCAL_SCRIPT_PATH")" + fi + REMOTE_CHECKSUM=$(file_sha256 "$actual_script_path") + remote_ver="$(read_ssh_monitor_version "$actual_script_path")" + log_action "сравнение: local=$LOCAL_SCRIPT_PATH ver=$local_ver sha=${LOCAL_CHECKSUM:0:16}…" + log_action "сравнение: remote=$actual_script_path ver=$remote_ver sha=${REMOTE_CHECKSUM:0:16}…" + if [ "$pull_ok" -eq 0 ]; then + log_action "примечание: git pull не удался — remote может быть устаревшим" + fi + if [ "$LOCAL_CHECKSUM" != "$REMOTE_CHECKSUM" ]; then + log_action "checksum различается — обновляем $LOCAL_SCRIPT_PATH" + if install_ssh_monitor_from "$actual_script_path"; then + script_changed=1 + SUMMARY_SCRIPT="обновлён" + SUMMARY_SCRIPT_REASON="${local_ver} → ${remote_ver}, sha изменился" + else + SUMMARY_SCRIPT="ошибка копирования" + log_update_summary + return 1 + fi + else + SUMMARY_SCRIPT="без изменений" + SUMMARY_SCRIPT_REASON="sha256 совпадает; версия local=$local_ver remote=$remote_ver" + log_action "ssh-monitor: файлы идентичны, копирование не требуется" + fi + fi + else + SUMMARY_SCRIPT="не найден в клоне" + SUMMARY_SCRIPT_REASON="проверьте структуру репозитория после git pull" + log_action "ERROR: ssh-monitor не найден в клоне $UPDATE_DIR/$SCRIPT_NAME" + if [ "$DEPLOY_MODE" -eq 1 ]; then + say_warn "ssh-monitor не найден в клоне $UPDATE_DIR/$SCRIPT_NAME" + fi + if [ "$DEBUG" -eq 1 ] && [ -d "$UPDATE_DIR/$SCRIPT_NAME" ]; then + find "$UPDATE_DIR/$SCRIPT_NAME" -type f -name "$SCRIPT_NAME" 2>/dev/null | while read -r found_file; do + log_message "DEBUG" "Найден файл: $found_file" + done + fi + fi + + if [ "$DEPLOY_MODE" -eq 1 ]; then + script_changed=1 + fi + + apply_runtime_security_hardening + + if [ "$script_changed" -eq 1 ] || [ "${SAC_CLIENT_CHANGED:-0}" -eq 1 ] || [ "${CONFIG_CHANGED:-0}" -eq 1 ]; then + restart_ssh_monitor_service + else + log_action "перезапуск сервиса не нужен (ни один файл не изменился)" + fi + + log_script_vars + log_update_summary + return 0 +} + +# === Основной блок выполнения === + +parse_command_line "$@" +deploy_require_root +require_repo_url + +load_ssh_monitor_perms_lib || true +if declare -F ssh_monitor_update_mark_begin >/dev/null 2>&1; then + ssh_monitor_update_mark_begin +fi + +if [ "$DEPLOY_MODE" -eq 1 ]; then + printf '\n=== ssh-monitor: первичная установка (--deploy) ===\n' + say_step "Репозиторий и файлы" +fi + +_updater_self="$(readlink -f "$0" 2>/dev/null || realpath "$0" 2>/dev/null || printf '%s' "$0")" +_updater_sha="$(file_sha256 "$_updater_self")" +log_message "INFO" "=== Script update started (deploy=$DEPLOY_MODE) ===" +log_action "запуск updater: $_updater_self (sha ${_updater_sha:0:16}…)" +log_action "хост=$(hostname 2>/dev/null || echo '?') user=$(id -un 2>/dev/null) uid=$(id -u 2>/dev/null)" +log_action "пути: LOCAL=$LOCAL_SCRIPT_PATH UPDATE_DIR=$UPDATE_DIR REPO=$REPO_URL" +debug_step "Инициализация скрипта обновления" + +if ! ensure_directories; then + log_message "INFO" "ERROR: Failed to ensure directories. Exiting." + exit 1 +fi + +maybe_enable_deploy_for_missing_install + +cleanup_update_state() { + if declare -F ssh_monitor_update_mark_end >/dev/null 2>&1; then + ssh_monitor_update_mark_end + fi +} +trap cleanup_update_state EXIT + +debug_step "Начинаем проверку и установку ipset (только --deploy / first_deploy)" +if [ "$DEPLOY_MODE" -eq 1 ]; then + if ! ensure_ipset_installed; then + log_message "ERROR" "CRITICAL: ipset installation or verification failed" + echo "ERROR: Работа deploy невозможна без установленного ipset" >&2 + exit 1 + fi +fi + +update_script "$@" +exit_code=$? + +if [ "$exit_code" -eq 0 ] && [ "$DEPLOY_MODE" -eq 1 ]; then + deploy_post_install + restart_ssh_monitor_service + if command -v systemctl &>/dev/null; then + if systemctl start ssh-monitor.service 2>/dev/null; then + say_ok "systemctl start ssh-monitor.service" + else + say_warn "systemctl start не выполнен (проверьте --check-config и конфиг)" + fi + fi +fi + +if [ $exit_code -eq 0 ]; then + log_message "INFO" "=== Script update completed successfully ===" + log_action "завершено успешно (код 0). Итог см. выше или: tail -30 $LOG_FILE" + if [ "$DEPLOY_MODE" -eq 1 ]; then + printf '\n=== Установка завершена. Отредактируйте %s и перезапустите сервис. ===\n' "$CONFIG_FILE" + fi +else + log_message "INFO" "=== Script update completed with errors (exit code: $exit_code) ===" + log_action "завершено с ошибкой (код $exit_code). См. $LOG_FILE" +fi + +exit $exit_code diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..4579786 --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +2.3.2-SAC