администрирование administration ,

Многоуровневая защита Linux-сервера с Telegram-мониторингом Layered Linux Server Security with Telegram Monitoring

Aug 07, 2026 · 187 мин. на прочтение 187 min read
Многоуровневая защита Linux-сервера с Telegram-мониторингом
Поделиться Share

Практическое руководство для Debian/Ubuntu и RHEL-подобных систем

Версия статьи: 1.0, 7 августа 2026 года. Решение основано на реально работающей конфигурации CentOS Stream 9 и повторно сверено с её фактическим состоянием. Все адреса, ключи, токены, идентификаторы чатов и другие секреты исключены.

Что мы строим

В этом руководстве мы собираем эшелонированную систему защиты Linux-сервера. Её компоненты решают разные задачи: ограничивают сетевой доступ, защищают SSH, обнаруживают и блокируют атаки, контролируют изменения важных файлов и сообщают администратору о событиях, требующих внимания. Сервер продолжает работать автономно: Telegram используется для уведомлений, а полные отчёты остаются на самом сервере.

После настройки сервер принимает только тот сетевой трафик, который нужен его сервисам. SSH допускает вход по ключам через отдельную административную учётную запись; root-вход и пароли отключены. Повторные попытки подбора автоматически блокируются, более сложные сценарии атак выявляет CrowdSec, целостность системных файлов контролирует AIDE, а обновления безопасности устанавливаются по расписанию. Все эти механизмы работают на самом сервере и не требуют постоянного участия администратора.

Администратор при этом видит, что происходит с защитой, и узнаёт о событиях, которые требуют проверки:

  • сразу после входа по SSH или использования sudo приходит оперативное уведомление;
  • после планового сканирования AIDE или rkhunter отправляется тревога, если найдены изменения или предупреждения;
  • один раз в день приходит краткий отчёт о блокировках, неудачных SSH-попытках, памяти, диске, средней нагрузке, необходимости перезагрузки и состоянии защитных служб.

Если Telegram недоступен, вход по SSH не блокируется. Плановые проверки продолжаются, а их отчёты сохраняются локально с правами 0600 root:root. Это важная часть конструкции: канал оповещения может сломаться, но сбой уведомлений не должен превращаться в отказ сервера или уничтожать данные для расследования.

Как устроена защита

Первый слой — межсетевой экран. Он разрешает только те порты, которые сервер действительно обслуживает: например, HTTPS и выбранный порт SSH. Всё остальное отбрасывается до того, как запрос попадёт в приложение. В этом решении firewalld управляет правилами nftables.

За межсетевым экраном работает усиленный SSH:

  • вход root запрещён;
  • парольная и интерактивная аутентификация с клавиатуры отключены;
  • доступ разрешён по публичным ключам;
  • число попыток аутентификации ограничено;
  • административная работа выполняется через отдельного пользователя и sudo;
  • нестандартный порт уменьшает фоновый шум в журналах, хотя сам по себе не считается защитой.

Fail2ban читает журнал SSH и реагирует на повторяющиеся ошибки с одного адреса. После нескольких неудачных попыток он добавляет временное правило в межсетевой экран. Отдельный jail — набор правил блокировки для конкретного источника событий — с именем recidive может блокировать повторных нарушителей дольше. Принципиальная деталь: Fail2ban должен знать фактический числовой порт SSH. Значение port = ssh обычно означает порт 22 и не подходит, если sshd перенесён, например, на 2207.

CrowdSec решает другую задачу. Он анализирует SSH, системные и веб-журналы по готовым сценариям, учитывает коллективную репутацию адресов и создаёт решения о блокировке. Сам механизм обнаружения (Security Engine) только выявляет события. Чтобы решения влияли на трафик, устанавливается bouncer межсетевого экрана — отдельный исполнитель, который получает их через локальный API и применяет через nftables. Fail2ban и CrowdSec частично пересекаются, но работают на разных данных: Fail2ban хорошо пресекает простой локальный перебор паролей, а CrowdSec распознаёт больше сценариев и использует общую базу наблюдений.

Что происходит после успешного входа

SSH остаётся под контролем PAM. Когда открывается новая сессия, небольшой скрипт, принадлежащий root, получает имя пользователя, удалённый адрес, службу и TTY, экранирует эти значения и отправляет сообщение в Telegram. Такой же hook — обработчик события — можно подключить к sudo, если администратору нужны уведомления о повышении привилегий.

Обработчик PAM объявлен как optional. Он запускает отправку отдельно и всегда разрешает процессу входа продолжиться. Текст из PAM передаётся скрипту как данные, а не вставляется в eval или динамически создаваемую команду оболочки. Это защищает от внедрения команд и не создаёт зависимость доступа к серверу от Telegram API.

Как контролируются файлы и состояние системы

AIDE строит базу контрольных сумм, владельцев, прав и других метаданных для выбранных файлов. Ежедневная проверка сравнивает текущее состояние с базой. Если файл добавлен, удалён или изменён, Telegram получает короткую сводку и первые пути, а полный отчёт записывается в каталог, доступный только root. База не обновляется автоматически: иначе злоумышленник или ошибочное обновление могли бы незаметно стать новым «нормальным» состоянием.

rkhunter запускается отдельно и ищет известные признаки руткитов, подозрительные разрешения и необычные системные объекты. Его результаты требуют проверки человеком: этот инструмент эвристический и может реагировать на легитимные обновления. Команда rkhunter --propupd также не выполняется автоматически, потому что она объявляет текущее состояние доверенным.

Обе проверки запускают таймеры systemd ночью со случайной задержкой. flock устанавливает блокировку файла и не позволяет двум копиям тяжёлого сканера работать одновременно, TimeoutStartSec ограничивает зависший запуск по времени, а logrotate сжимает и удаляет старые отчёты по заданной политике.

Как система сообщает о собственных неисправностях

Мониторинг полезен только тогда, когда контролирует не только сервер, но и себя. Поэтому ежедневный дайджест не ограничивается проверкой, что таймер включён. Для каждой службы типа oneshot, то есть выполняющей одну задачу и завершающейся, он читает Result и ExecMainStatus последнего запуска. Это позволяет отличить нормально завершившуюся проверку AIDE от таймера, который формально активен, но уже несколько дней запускает скрипт с ошибкой.

Токен Telegram-бота и идентификатор чата хранятся в отдельном файле, доступном только root. Общая функция отправки задаёт короткие сетевые тайм-ауты, проверяет HTTP-ответ и поле ok в JSON, ограничивает длину сообщения и экранирует HTML. Секреты и текст сообщения не передаются в аргументах процесса curl.

Автоматические обновления безопасности закрывают известные уязвимости через unattended-upgrades или dnf-automatic. Автоматическая перезагрузка выключена: если новое ядро или библиотека требуют перезагрузки, это попадает в ежедневный дайджест. База AIDE после обновления пакетов не принимается автоматически. Администратор сначала сверяет изменения с журналом пакетного менеджера и только потом вручную обновляет доверенную базу (baseline).

Как выглядит работа системы в обычный день

При штатной работе сервер почти не беспокоит администратора. Fail2ban применяет локальные блокировки, а bouncer межсетевого экрана — решения CrowdSec. Чистые проверки AIDE и rkhunter не отправляют тревог. Один раз в день приходит короткий отчёт о состоянии. Полные технические журналы остаются на сервере и ротируются.

Если начинается перебор SSH, Fail2ban блокирует источник после заданного числа ошибок. CrowdSec может независимо создать более широкое решение. Если кто-то успешно входит, администратор сразу видит пользователя и источник подключения. Если после обновления или взлома меняются системные файлы, AIDE показывает конкретные пути. Если сканер, Telegram API или служба systemd ломается, ошибка становится видна через статус сервиса и дайджест, а не теряется молча.

Схема ниже показывает путь сетевого события через профилактические меры и средства обнаружения, а также отдельный контур локальных проверок и уведомлений. По ней видно, какой компонент блокирует трафик, какой только обнаруживает событие и где сохраняются подробные данные.

Интернет
   │
   ├── firewalld / nftables ───────────── разрешает только нужные порты
   │
   ├── Fail2ban ───────────────────────── банит повторные ошибки SSH
   │
   ├── CrowdSec + firewall bouncer ───── сценарии атак и репутация адресов
   │
   └── sshd ───────────────────────────── ключи, запрет root и паролей
          │
          └── PAM ─────────────────────── уведомление о входе и sudo

Файловая система ── AIDE ─────────────── контроль целостности
Хост ────────────── rkhunter ──────────── дополнительная эвристика
Пакеты ──────────── unattended-upgrades /
                     dnf-automatic ────── security-обновления

systemd timers ──── расписание и состояние последних запусков
Telegram ────────── тревоги и ежедневный health report
Локальные журналы ─ полные root-only данные для расследования

Эта система уменьшает риск перебора учётных данных, входа с украденным паролем, незамеченных атак из веб- и системных журналов, скрытых изменений файлов и пропущенных обновлений. Она также сокращает время между событием и реакцией администратора.

Она не заменяет резервные копии, TLS, безопасную разработку приложений, хранение секретов, защиту панели VPS-провайдера и заранее проверенный план восстановления после компрометации. Если злоумышленник уже получил полный root-доступ, локальные инструменты и отчёты тоже могут быть изменены. Для серверов с повышенными требованиями журналы дополнительно отправляют на отдельный хост или в SIEM, а базу AIDE хранят вне проверяемой машины.

Поддерживаемые системы

Основной путь рассчитан на:

  • Debian 12+ и Ubuntu 22.04+;
  • RHEL 9, Rocky Linux 9, AlmaLinux 9, CentOS Stream 9;
  • Fedora с поправкой на названия пакетов.

Команды выполняются от обычного административного пользователя через sudo. Перед началом убедитесь, что доступна аварийная веб-, VNC- или последовательная консоль VPS-провайдера.

Важные правила безопасности

  1. Не закрывайте текущую SSH-сессию, пока вход по ключу не проверен во второй сессии.
  2. Сначала откройте новый SSH-порт в межсетевом экране, затем меняйте sshd.
  3. Перед изменением PAM, SSH и межсетевого экрана создавайте резервные копии.
  4. Не обновляйте базу AIDE автоматически после тревоги. Сначала исследуйте каждое изменение.
  5. Не храните Telegram-токен в Git, истории командной оболочки, аргументах процессов или доступных всем файлах.
  6. Не используйте постоянный NOPASSWD: ALL для обычной учётной записи. Если автоматизации действительно нужен root, выделите отдельного пользователя и отдельный ключ.
  7. CrowdSec без компонента автоматического реагирования обнаруживает события, но не блокирует их.
  8. Активный таймер systemd не доказывает успешность последнего запуска. Проверяйте Result и ExecMainStatus сервиса.

Часть I. Подготовка

1. Определяем дистрибутив и создаём резервную копию

Сначала фиксируем исходное состояние: семейство и версию системы, ядро и наличие утилит, от которых зависят следующие шаги. Затем сохраняем конфигурацию SSH, PAM, межсетевого экрана и список модулей systemd в закрытом каталоге; эта копия нужна для сравнения и быстрого возврата, если усиление доступа даст неожиданный результат.

# Подключаем доверенный файл с переменными или общими функциями.
. /etc/os-release
# Формируем или выводим данные в заданном безопасном формате.
printf 'OS=%s %s\n' "$ID" "$VERSION_ID"
# Выводим версию работающего ядра.
uname -r
# Проверяем наличие всех требуемых исполняемых файлов.
command -v systemctl sudo curl python3 flock

Следующий блок создаёт закрытый каталог с уникальной временной меткой и складывает туда копии конфигурации и снимки правил. Выполняйте его на сервере; переменная BACKUP останется доступна в текущей сессии root/sudo для дальнейшего отката. Создайте каталог резервной копии:

# Задаём уникальный путь закрытой резервной копии.
BACKUP="/root/server-security-backup-$(date +%Y%m%d-%H%M%S)"
# Создаём каталог или устанавливаем файл с заданными владельцем и правами.
sudo install -d -m 0700 "$BACKUP"

# Сохраняем копию файла или дерева с исходными метаданными.
sudo cp -a /etc/ssh "$BACKUP/ssh"
# Сохраняем копию файла или дерева с исходными метаданными.
sudo cp -a /etc/pam.d "$BACKUP/pam.d"
# Сохраняем копию файла или дерева с исходными метаданными.
sudo cp -a /etc/fail2ban "$BACKUP/fail2ban" 2>/dev/null || true
# Изменяем или проверяем постоянное правило firewalld.
sudo firewall-cmd --list-all 2>/dev/null \
  | sudo tee "$BACKUP/firewalld-before.txt" >/dev/null || true
# Просматриваем фактические правила nftables.
sudo nft list ruleset 2>/dev/null \
  | sudo tee "$BACKUP/nft-before.txt" >/dev/null || true
# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl list-unit-files \
  | sudo tee "$BACKUP/unit-files-before.txt" >/dev/null

Не копируйте архив с секретами в публичное хранилище. Каталог должен оставаться root:root 0700.

2. Устанавливаем базовые пакеты

Набор одинаков по назначению, но различается по именам и менеджеру пакетов: APT используется в Debian/Ubuntu, DNF — в RHEL-подобных системах. Помимо защитных сервисов устанавливаем утилиты для блокировок, файлов блокировки, ротации журналов и автоматических обновлений; после шага должны быть доступны все команды, а текущий SSH-порт — разрешён в firewalld.

Debian/Ubuntu

APT сначала обновляет индекс, затем устанавливает полный набор зависимостей одной транзакцией. После выполнения команды все перечисленные утилиты должны находиться через command -v.

# Обновляем локальный индекс пакетов APT.
sudo apt update
# Устанавливаем перечисленные пакеты через APT.
sudo apt install -y \
  openssh-server curl ca-certificates python3 util-linux \
  fail2ban aide rkhunter logrotate unattended-upgrades

Отдельно устанавливаем firewalld, который будет управлять постоянными и текущими правилами nftables. На этом шаге пакет только добавляется; включение выполняется после разрешения действующего SSH-порта. Установите firewalld:

# Устанавливаем перечисленные пакеты через APT.
sudo apt install -y firewalld

RHEL/Rocky/Alma/CentOS Stream/Fedora

EPEL добавляет пакеты, которых нет в базовых репозиториях RHEL-подобных систем; после его подключения DNF устанавливает тот же функциональный набор, включая интеграцию Fail2ban с firewalld.

# Устанавливаем перечисленные пакеты через DNF.
sudo dnf install -y epel-release
# Устанавливаем перечисленные пакеты через DNF.
sudo dnf install -y \
  openssh-server curl ca-certificates python3 util-linux \
  firewalld fail2ban fail2ban-firewalld aide rkhunter \
  logrotate dnf-automatic

Блок читает фактический порт sshd, проверяет непустой результат и добавляет его либо в работающий firewalld, либо в конфигурацию ещё не запущенной службы. Продолженные строки с \ образуют единые команды: комментарии внутри них нарушили бы вставку целиком. Не включайте новый межсетевой экран, пока в его постоянной конфигурации не разрешён текущий SSH-порт:

# Читаем фактический порт из эффективной конфигурации sshd.
CURRENT_SSH_PORT=$(sudo sshd -T | awk '$1 == "port" {print $2; exit}')
# Проверяем, что sshd сообщил непустой числовой порт.
test -n "$CURRENT_SSH_PORT"

# Проверяем условие и выбираем дальнейшую ветвь выполнения.
if systemctl is-active --quiet firewalld; then
  # Изменяем или проверяем постоянное правило firewalld.
  sudo firewall-cmd --permanent \
    --add-port="${CURRENT_SSH_PORT}/tcp"
  # Изменяем или проверяем постоянное правило firewalld.
  sudo firewall-cmd --reload
# Если условие не выполнено, переходим к безопасной альтернативе.
else
  # Добавляем разрешение в постоянную конфигурацию ещё не запущенного firewalld.
  sudo firewall-offline-cmd --zone=public \
    --add-port="${CURRENT_SSH_PORT}/tcp"
  # Управляем systemd-unit или читаем его фактическое состояние.
  sudo systemctl enable --now firewalld
fi

# Изменяем или проверяем постоянное правило firewalld.
sudo firewall-cmd --query-port="${CURRENT_SSH_PORT}/tcp"

Эти команды выполняются на сервере и подтверждают работающий SSH, активный firewalld и наличие всех консольных утилит. Для SSH учитываются оба распространённых имени службы systemd: sshd и ssh. Проверьте пакеты и сервисы, а не полагайтесь на отсутствие ошибок в установщике:

# Проверяем фактическое состояние systemd-unit.
systemctl is-active sshd 2>/dev/null || systemctl is-active ssh
# Проверяем фактическое состояние systemd-unit.
systemctl is-active firewalld
# Проверяем наличие всех требуемых исполняемых файлов.
command -v fail2ban-client aide rkhunter curl python3 flock

Часть II. SSH и межсетевой экран без риска потерять доступ

3. Создаём административного пользователя и ключ

Отделяем повседневное администрирование от учётной записи root и заменяем пароль криптографическим ключом Ed25519. Закрытая часть ключа остаётся на клиентском компьютере, а сервер получает только открытый ключ; прежде чем запрещать пароли, добиваемся успешного входа пользователя admin во второй сессии.

На клиенте создаём новый закрытый и открытый ключи; результатом станут id_ed25519 и id_ed25519.pub либо файлы под выбранным при запросе именем. Закрытый ключ не копируется на сервер. На клиентском компьютере, не на сервере:

# Создаём пару ключей Ed25519 на клиентском компьютере.
ssh-keygen -t ed25519 -a 64 -C "admin@my-server"

Приватный файл ~/.ssh/id_ed25519 никогда никому не передавайте. На сервер устанавливается только .pub.

На сервере этот блок создаёт admin при необходимости, добавляет его в системную административную группу и готовит authorized_keys с закрытыми правами. В строках ветвей case-подобной логики уже есть исходные пояснения дистрибутивов; они сохранены без изменения. На сервере:

# Проверяем наличие пользователя и создаём его только при отсутствии.
id -u admin >/dev/null 2>&1 \
  || sudo useradd --create-home --shell /bin/bash admin

# Проверяем условие и выбираем дальнейшую ветвь выполнения.
if getent group sudo >/dev/null; then
  # Добавляем `admin` в группу администраторов Debian/Ubuntu.
  sudo usermod -aG sudo admin      # Debian/Ubuntu
# Если предыдущее условие ложно, проверяем альтернативный признак.
elif getent group wheel >/dev/null; then
  # Добавляем `admin` в административную группу RHEL-подобной системы.
  sudo usermod -aG wheel admin     # RHEL-подобные
# Если условие не выполнено, переходим к безопасной альтернативе.
else
  # Сообщаем об отсутствии подходящей административной группы.
  echo 'Не найдена административная группа sudo/wheel' >&2
  # Завершаем скрипт с выбранным кодом результата.
  exit 1
fi

# Создаём каталог или устанавливаем файл с заданными владельцем и правами.
sudo install -d -o admin -g admin -m 0700 /home/admin/.ssh
# Создаём `authorized_keys` только при отсутствии, не затирая существующие ключи.
sudo test -e /home/admin/.ssh/authorized_keys \
  || sudo install -o admin -g admin -m 0600 /dev/null \
       /home/admin/.ssh/authorized_keys
# Закрепляем ожидаемого владельца и группу.
sudo chown admin:admin /home/admin/.ssh/authorized_keys
# Устанавливаем минимально необходимые права доступа.
sudo chmod 0600 /home/admin/.ssh/authorized_keys

Команда дописывает только открытый ключ, удаляет точные дубликаты и восстанавливает владельца, режим и SELinux-контекст. Продолженный конвейер команд поясняется целиком перед первой строкой, чтобы блок можно было безопасно скопировать и вставить. Добавьте публичный ключ, не перезаписывая существующие:

# Формируем или выводим данные в заданном безопасном формате.
printf '%s\n' 'ssh-ed25519 ЗАМЕНИТЕ_НА_СВОЙ_ПУБЛИЧНЫЙ_КЛЮЧ' \
  | sudo tee -a /home/admin/.ssh/authorized_keys >/dev/null
# Удаляем дубликаты ключей без изменения файла назначения.
sudo sort -u -o /home/admin/.ssh/authorized_keys /home/admin/.ssh/authorized_keys
# Закрепляем ожидаемого владельца и группу.
sudo chown admin:admin /home/admin/.ssh/authorized_keys
# Устанавливаем минимально необходимые права доступа.
sudo chmod 0600 /home/admin/.ssh/authorized_keys
# Восстанавливаем штатные SELinux-контексты, если SELinux доступен.
sudo restorecon -RF /home/admin/.ssh 2>/dev/null || true

Тест запускается на клиенте с BatchMode=yes, поэтому пароль не сможет скрыть ошибку ключевой аутентификации. Успешная команда должна вывести сведения об admin; проверка беспарольного sudo здесь диагностическая и не требуется для продолжения. Во второй локальной вкладке проверьте вход, запретив переход к парольной аутентификации:

# Открываем отдельную тестовую SSH-сессию только по указанному ключу.
ssh -o BatchMode=yes -o IdentitiesOnly=yes \
  -i ~/.ssh/id_ed25519 admin@SERVER_IP 'id; sudo -n true || true'

Если ключ не работает, не отключайте пароль.

4. Выбираем SSH-порт и заранее настраиваем межсетевой экран

Меняем порт SSH только после добавления соответствующего разрешения в постоянную конфигурацию межсетевого экрана, иначе следующее перечитывание конфигурации может отрезать удалённый доступ. Переменная SSH_PORT позволяет использовать одно числовое значение в командах, а итоговая проверка должна показать новый порт среди разрешённых.

Нестандартный порт уменьшает шум в логах, но не является самостоятельной защитой. В примерах используется переменная:

# Сохраняем выбранный новый порт SSH.
SSH_PORT=2207

На сервере добавляем порт в постоянную зону, перечитываем правила и сразу проверяем результат через firewalld. Ответ последней команды должен быть yes до правки sshd. Откройте новый порт до изменения SSH:

# Изменяем или проверяем постоянное правило firewalld.
sudo firewall-cmd --permanent --add-port="${SSH_PORT}/tcp"
# Изменяем или проверяем постоянное правило firewalld.
sudo firewall-cmd --reload
# Изменяем или проверяем постоянное правило firewalld.
sudo firewall-cmd --query-port="${SSH_PORT}/tcp"

Пример разрешает HTTP/HTTPS, удаляет стандартный сервис ssh и выводит итоговую зону. Применяйте удаление только после успешного входа через новый числовой порт; набор веб-служб должен соответствовать реальной роли сервера. Если сервер предоставляет только SSH и веб-службы, итоговая конфигурация межсетевого экрана может выглядеть так:

# Изменяем или проверяем постоянное правило firewalld.
sudo firewall-cmd --permanent --add-service=http
# Изменяем или проверяем постоянное правило firewalld.
sudo firewall-cmd --permanent --add-service=https
# Изменяем или проверяем постоянное правило firewalld.
sudo firewall-cmd --permanent --remove-service=ssh
# Изменяем или проверяем постоянное правило firewalld.
sudo firewall-cmd --reload
# Изменяем или проверяем постоянное правило firewalld.
sudo firewall-cmd --list-all

Удаляйте стандартную службу ssh только после проверки нового порта.

5. Усиливаем sshd

sshd — это серверная часть OpenSSH: процесс, который принимает входящие SSH-подключения и решает, кому и каким способом разрешён вход. В этом разделе мы уменьшим поверхность атаки: перенесём SSH на выбранный порт, запретим прямой вход под root, отключим пароли и оставим аутентификацию по ключам. Дополнительно ограничим число попыток входа и отключим неиспользуемое перенаправление X11.

Основной файл /etc/ssh/sshd_config можно редактировать напрямую, но так сложнее отличать собственные настройки от параметров, установленных пакетом. Вместо этого используем drop-in — отдельный небольшой конфигурационный файл в каталоге /etc/ssh/sshd_config.d/. OpenSSH читает такие файлы вместе с основным конфигом, поэтому настройки усиления можно проверить, заменить или удалить независимо от остальной конфигурации.

Имя 60-hardening.conf задаёт понятное назначение файла и его место в порядке чтения конфигурации. Сначала создаём каталог drop-in, если его ещё нет, затем открываем новый файл в безопасном редакторе sudoedit:

# Создаём каталог или устанавливаем файл с заданными владельцем и правами.
sudo install -d -m 0755 /etc/ssh/sshd_config.d
# Открываем новый drop-in через редактор с безопасным повышением прав.
sudoedit /etc/ssh/sshd_config.d/60-hardening.conf

В открытом drop-in сохраните параметры ниже без синтаксиса командной оболочки: это директивы самого sshd. После перечитывания конфигурации демон будет слушать порт 2207, принимать ключи, запрещать вход root и пароли и завершать неактивные соединения по заданной политике. Содержимое:

Port 2207
PermitRootLogin no
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
MaxAuthTries 3
X11Forwarding no
ClientAliveInterval 300
ClientAliveCountMax 2
UsePAM yes

Сначала sshd разбирает всю конфигурацию без запуска, затем выводит реально применённые значения с учётом основного файла и drop-in. Продолженный конвейер фильтрует этот вывод как одну команду. Проверьте синтаксис и эффективные значения:

# Проверяем синтаксис или эффективную конфигурацию sshd.
sudo sshd -t
# Проверяем синтаксис или эффективную конфигурацию sshd.
sudo sshd -T | grep -E \
  '^(port|permitrootlogin|pubkeyauthentication|passwordauthentication|kbdinteractiveauthentication|maxauthtries|x11forwarding|clientaliveinterval|clientalivecountmax) '

Перечитывание применяет проверенную конфигурацию, не обрывая текущую сессию; запасное имя ssh учитывает Debian/Ubuntu. Затем ss должен показать слушающий сокет на ${SSH_PORT}. Не используйте systemctl restart sshd вслепую. Сначала перечитайте конфигурацию:

# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl reload sshd 2>/dev/null || sudo systemctl reload ssh
# Проверяем, что sshd действительно слушает ожидаемый TCP-порт.
sudo ss -ltnp | grep ":${SSH_PORT} "

Запустите блок на клиентском компьютере: он запрещает переход к парольной аутентификации, явно выбирает ключ и новый порт. Ожидаемый вывод — key login OK; только после него безопасно закрывать старую сессию. Проверьте новую сессию:

# Открываем отдельную тестовую SSH-сессию только по указанному ключу.
ssh -o BatchMode=yes -o IdentitiesOnly=yes \
  -i ~/.ssh/id_ed25519 -p 2207 admin@SERVER_IP 'printf "key login OK\n"'

Только после успеха можно закрыть старую сессию и удалить правило старого SSH-порта.


Часть III. Fail2ban и CrowdSec

6. Настраиваем Fail2ban на фактический SSH-порт

Fail2ban объединяет правила реагирования в jail — набор фильтра журнала, порога попыток, срока блокировки и действия межсетевого экрана для конкретной службы. Здесь основной jail защищает фактический порт sshd, а recidive надолго блокирует адреса, которые повторно попадают под блокировку; после запуска оба механизма должны загрузиться без ошибок.

Не используйте port = ssh, если sshd слушает нестандартный порт: имя сервиса обычно разрешается в 22/tcp.

Сохраните этот drop-in Fail2ban на сервере: секция [DEFAULT] задаёт общую политику, [sshd] — быструю блокировку на порту 2207, а [recidive] — длительную реакцию на повторных нарушителей. Результатом будет конфигурация, которую можно проверить до перезапуска демона. Создайте /etc/fail2ban/jail.d/sshd.local:

[DEFAULT]
backend = systemd
bantime = 1h
findtime = 10m
maxretry = 5
bantime.increment = true
bantime.factor = 2
bantime.maxtime = 1w
ignoreip = 127.0.0.1/8 ::1
banaction = firewallcmd-rich-rules
banaction_allports = firewallcmd-rich-rules

[sshd]
enabled = true
port = 2207
maxretry = 3

[recidive]
enabled = true
backend = systemd
bantime = 1w
findtime = 1d
maxretry = 5
banaction = firewallcmd-rich-rules
banaction_allports = firewallcmd-rich-rules

На RHEL с fail2ban-firewalld обычно уже создаётся /etc/fail2ban/jail.d/00-firewalld.conf. Если нет, добавьте в [DEFAULT]:

banaction = firewallcmd-rich-rules
banaction_allports = firewallcmd-rich-rules

Сначала Fail2ban полностью разбирает конфигурацию, затем служба включается и перезапускается для применения jail. Первый шаг должен завершиться без ошибок; иначе сервис не перезапускайте. Перед перезапуском:

# Проверяем конфигурацию, готовность или состояние Fail2ban.
sudo fail2ban-client -t
# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl enable --now fail2ban
# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl restart fail2ban

Цикл опрашивает сам Fail2ban до 20 секунд, а не принимает активную службу systemd за доказательство готовности. После цикла обязательный ping и статусы должны показать отвечающий демон и загруженный jail sshd. Дождитесь готовности приложения:

# Повторяем проверку ограниченное число раз, чтобы дождаться готовности сервиса.
for i in $(seq 1 20); do
  # Проверяем конфигурацию, готовность или состояние Fail2ban.
  sudo fail2ban-client ping >/dev/null 2>&1 && break
  # Делаем короткую паузу перед следующей проверкой готовности.
  sleep 1
done
# Проверяем конфигурацию, готовность или состояние Fail2ban.
sudo fail2ban-client ping
# Проверяем конфигурацию, готовность или состояние Fail2ban.
sudo fail2ban-client status
# Проверяем конфигурацию, готовность или состояние Fail2ban.
sudo fail2ban-client status sshd

Прочитайте загруженные значения jail через клиент и дополнительно найдите порт 2207 в отладочном представлении конфигурации. Вывод должен совпасть с заданными порогами и фактическим портом sshd. Проверьте эффективные значения:

# Проверяем конфигурацию, готовность или состояние Fail2ban.
sudo fail2ban-client get sshd maxretry
# Проверяем конфигурацию, готовность или состояние Fail2ban.
sudo fail2ban-client get sshd findtime
# Проверяем конфигурацию, готовность или состояние Fail2ban.
sudo fail2ban-client get sshd bantime
# Проверяем конфигурацию, готовность или состояние Fail2ban.
sudo fail2ban-client -d | grep -A3 -B3 "'port', '2207'"

Не тестируйте бан с единственного адреса, через который администрируете сервер. Используйте отдельное соединение и заранее подготовленную консоль провайдера.

Эта команда снимает тестовую блокировку одного документального адреса в jail sshd. Заменяйте TEST_IP только адресом, который намеренно использовали в безопасном тесте. Разбан:

# Проверяем конфигурацию, готовность или состояние Fail2ban.
sudo fail2ban-client set sshd unbanip TEST_IP

7. Устанавливаем CrowdSec и bouncer межсетевого экрана

Механизм обнаружения CrowdSec (Security Engine) распознаёт сценарии атак и создаёт решения, но сам трафик не фильтрует. Bouncer — отдельный исполнитель, который получает эти решения через локальный API и переносит их в nftables; в конце раздела проверяем не только обе службы, но и появление тестового решения в межсетевом экране.

CrowdSec состоит минимум из двух частей:

  • механизм обнаружения (Security Engine) анализирует журналы и создаёт решения;
  • компонент автоматического реагирования применяет решения к трафику.

Актуальные официальные инструкции: https://docs.crowdsec.net/u/getting_started/installation/linux/.

Вместо безусловного curl | sh загрузите установочный скрипт отдельно и осмотрите его:

# Загружаем установочный скрипт во временный файл без запуска.
curl -fsSL https://install.crowdsec.net -o /tmp/install-crowdsec.sh
# Просматриваем загруженный скрипт перед выполнением.
less /tmp/install-crowdsec.sh
# Запускаем предварительно просмотренный bootstrap с повышенными правами.
sudo sh /tmp/install-crowdsec.sh
# Удаляем временный bootstrap после установки.
rm -f /tmp/install-crowdsec.sh

Debian/Ubuntu

Сначала обновляем индекс и проверяем, из какого подключённого репозитория будет взят CrowdSec. Установка должна добавить механизм обнаружения и bouncer межсетевого экрана как управляемые пакетами компоненты.

# Обновляем локальный индекс пакетов APT.
sudo apt update
# Проверяем доступную версию и репозиторий пакета.
apt-cache policy crowdsec
# Устанавливаем перечисленные пакеты через APT.
sudo apt install -y crowdsec crowdsec-firewall-bouncer-iptables

RHEL/CentOS/Fedora

DNF показывает метаданные кандидата до установки, чтобы можно было проверить репозиторий и версию. Затем одной командой устанавливаются механизм обнаружения и bouncer.

# Показываем источник и метаданные пакета до установки.
sudo dnf info crowdsec
# Устанавливаем перечисленные пакеты через DNF.
sudo dnf install -y crowdsec crowdsec-firewall-bouncer-iptables

Официальная документация использует имя пакета crowdsec-firewall-bouncer-iptables; сам bouncer может работать в режиме nftables. Предпочитайте пакетную установку. Если бинарник установлен вручную и rpm -qf/dpkg -S не находит владельца, обновления и контроль происхождения становятся вашей ответственностью.

Блок включает механизм обнаружения и bouncer при загрузке, проверяет их активность и читает результат последнего запуска. Для обеих служб ожидаются активное состояние, Result=success и ExecMainStatus=0. Включите и проверьте:

# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl enable --now crowdsec
# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl enable --now crowdsec-firewall-bouncer
# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl is-active crowdsec crowdsec-firewall-bouncer
# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl show crowdsec crowdsec-firewall-bouncer \
  -p Result -p ExecMainStatus

Коллекции — готовые наборы парсеров и сценариев CrowdSec; устанавливайте только те, для которых сервер действительно пишет журналы. После изменения набора механизм обнаружения перезапускается, чтобы загрузить конфигурацию. Установите коллекции по реально работающим сервисам:

# Управляем объектами CrowdSec или читаем их фактическое состояние.
sudo cscli collections install crowdsecurity/linux crowdsecurity/sshd
# Если есть Nginx:
sudo cscli collections install crowdsecurity/nginx \
  crowdsecurity/base-http-scenarios crowdsecurity/http-cve
# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl restart crowdsec

Команды показывают установленные коллекции, обработанные строки, решения, зарегистрированный bouncer и свежие журналы двух сервисов. Так можно отличить пустую установку от системы, которая читает события и применяет решения. Проверьте источники журналов и метрики:

# Управляем объектами CrowdSec или читаем их фактическое состояние.
sudo cscli collections list
# Управляем объектами CrowdSec или читаем их фактическое состояние.
sudo cscli metrics
# Управляем объектами CrowdSec или читаем их фактическое состояние.
sudo cscli decisions list
# Управляем объектами CrowdSec или читаем их фактическое состояние.
sudo cscli bouncers list
# Читаем относящиеся к сервису события systemd journal.
sudo journalctl -u crowdsec -u crowdsec-firewall-bouncer \
  --since '30 minutes ago' --no-pager

Учитывайте важное различие: cscli decisions list -o json может возвращать список предупреждений (alerts), внутри которых находятся решения (decisions). Для автоматизации не считайте строки таблицы; парсите JSON в соответствии с фактической версией.

Безопасный тест локального решения выполняйте для документального адреса, который не принадлежит вам:

# Управляем объектами CrowdSec или читаем их фактическое состояние.
sudo cscli decisions add --ip 192.0.2.123 --duration 2m --reason test
# Управляем объектами CrowdSec или читаем их фактическое состояние.
sudo cscli decisions list
# Просматриваем фактические правила nftables.
sudo nft list ruleset | grep -i crowdsec
# Управляем объектами CrowdSec или читаем их фактическое состояние.
sudo cscli decisions delete --ip 192.0.2.123

Никогда не публикуйте API-ключ bouncer из /etc/crowdsec/bouncers/*.yaml.


Часть IV. Общий Telegram-транспорт

8. Создаём конфигурацию Telegram Bot API

Секреты Telegram выносим в отдельный файл окружения, доступный только root, чтобы скрипты могли читать их, не встраивая токен в код или аргументы процессов. После сохранения проверяем владельца, режим 0600 и имена переменных, не выводя значения на экран.

В другой статье автора в этом же блоге — «Базовые сведения о Telegram Bot API» — разобраны BotFather, токен, идентификатор чата и базовые методы API.

Создайте бота через официального @BotFather, начните диалог с ним и получите идентификатор чата. Не вставляйте токен прямо в команду оболочки: он попадёт в историю.

# Создаём каталог или устанавливаем файл с заданными владельцем и правами.
sudo install -d -o root -g root -m 0700 /etc/server-security-monitor
# Создаём файл секретов только при отсутствии, не затирая существующую конфигурацию.
sudo test -e /etc/server-security-monitor/telegram.env \
  || sudo install -o root -g root -m 0600 /dev/null \
       /etc/server-security-monitor/telegram.env
# Закрепляем ожидаемого владельца и группу.
sudo chown root:root /etc/server-security-monitor/telegram.env
# Устанавливаем минимально необходимые права доступа.
sudo chmod 0600 /etc/server-security-monitor/telegram.env
# Открываем закрытый файл секретов через `sudoedit`.
sudoedit /etc/server-security-monitor/telegram.env

В telegram.env введите только две пары ИМЯ=значение, заменив заполнители своими секретами; командная оболочка игнорирует добавленные строки-комментарии. Файл остаётся на сервере и не должен попадать в Git или вывод терминала. Содержимое:

# Задаём токен бота-заполнитель, который нужно заменить локально.
TELEGRAM_BOT_TOKEN="ЗАМЕНИТЕ_НА_ТОКЕН"
# Задаём идентификатор чата-заполнитель, который нужно заменить локально.
TELEGRAM_CHAT_ID="ЗАМЕНИТЕ_НА_CHAT_ID"

Команды выводят метаданные файла и только имена переменных с маркером <set>. Значения токена и идентификатора чата при этой проверке не попадают в терминал. Проверьте только структуру и права, не печатая значения:

# Выводим только права, владельца и имя защищённого файла.
sudo stat -c '%A %U:%G %n' /etc/server-security-monitor/telegram.env
# Показываем имена заданных переменных, не раскрывая их значения.
sudo awk -F= '/^[A-Z_]+=/ {print $1"=<set>"}' \
  /etc/server-security-monitor/telegram.env

9. Общая библиотека

Повторяющуюся работу с Telegram помещаем в common.sh: библиотека загрузит закрытый конфиг, экранирует HTML, ограничит размер сообщения и проверит ответ Bot API. Все последующие скрипты будут подключать один и тот же проверенный транспорт, поэтому ошибка доставки станет кодом ошибки, а секреты не окажутся в командной строке curl.

Блок создаёт принадлежащий root каталог библиотек с режимом 0750 и открывает common.sh через sudoedit. Результатом должен стать файл, который обычный пользователь не может изменить. Создайте каталог:

# Создаём каталог или устанавливаем файл с заданными владельцем и правами.
sudo install -d -o root -g root -m 0750 \
  /usr/local/libexec/server-security-monitor
# Открываем файл общей библиотеки через `sudoedit`.
sudoedit /usr/local/libexec/server-security-monitor/common.sh

Сохраните следующий Bash-код в common.sh; он является библиотекой и сам по себе ничего не отправляет. Встроенные документы (heredoc) на Python кодируют форму и проверяют JSON-ответ: строки между <<'PY' и PY нельзя комментировать как Bash, потому что это изменило бы передаваемую программу на Python. Содержимое common.sh:

#!/usr/bin/env bash
# Общие функции. Файл должен принадлежать root и не изменяться обычными пользователями.

# Задаём путь к закрытому Telegram-конфигу с возможностью тестового переопределения.
SSM_CONF=${SSM_CONF:-/etc/server-security-monitor/telegram.env}
# Задаём абсолютный путь к curl с возможностью тестового переопределения.
SSM_CURL=${SSM_CURL:-/usr/bin/curl}

# Объявляем функцию безопасной загрузки Telegram-конфигурации.
load_telegram_config() {
  # Проверяем условие и завершаем шаг ранним безопасным результатом при необходимости.
  [[ -r "$SSM_CONF" ]] || {
    # Формируем или выводим данные в заданном безопасном формате.
    printf 'Telegram config is not readable: %s\n' "$SSM_CONF" >&2
    # Возвращаем вызывающей функции код ошибки.
    return 2
  }

  # Это root-only доверенный конфигурационный файл.
  # shellcheck disable=SC1090
  # Экспортируем переменные, которые будут загружены из доверенного файла.
  set -a
  # Подключаем доверенный файл с переменными или общими функциями.
  . "$SSM_CONF"
  # Отключаем автоматический экспорт после загрузки конфигурации.
  set +a

  # Требуем непустое значение обязательной переменной конфигурации.
  : "${TELEGRAM_BOT_TOKEN:?TELEGRAM_BOT_TOKEN is missing}"
  # Требуем непустое значение обязательной переменной конфигурации.
  : "${TELEGRAM_CHAT_ID:?TELEGRAM_CHAT_ID is missing}"
}

# Объявляем фильтр для экранирования специальных HTML-символов.
html_escape() {
  # Экранируем символы, имеющие специальное значение в HTML.
  sed -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g'
}

# Объявляем общую функцию отправки сообщения через Telegram Bot API.
tg_send() {
  # Формируем текст уведомления из уже экранированных полей.
  local msg=${1:-}
  # Проверяем условие и завершаем шаг ранним безопасным результатом при необходимости.
  [[ -n "$msg" ]] || return 0
  # Загружаем токен и chat ID перед формированием запроса.
  load_telegram_config

  # Telegram принимает до 4096 символов; оставляем запас.
  if ((${#msg} > 3900)); then
    # Формируем текст уведомления из уже экранированных полей.
    msg="${msg:0:3820}
…сообщение сокращено; полный отчёт сохранён на сервере"
  fi

  # Объявляем локальные переменные функции, чтобы не менять глобальное окружение.
  local response body
  # Кодируем параметры запроса без раскрытия их в argv процесса.
  body=$(CHAT_ID="$TELEGRAM_CHAT_ID" MESSAGE="$msg" python3 - <<'PY'
import os, urllib.parse
print(urllib.parse.urlencode({
    "chat_id": os.environ["CHAT_ID"],
    "text": os.environ["MESSAGE"],
    "parse_mode": "HTML",
    "disable_web_page_preview": "true",
}))
PY
  )

  # URL с токеном передаётся через отдельный FD, а POST body — через stdin:
  # токен, chat ID и текст не появляются в argv процесса curl.
  exec 3<<<"url = \"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage\""
  # Отправляем форму с тайм-аутами и сохраняем JSON-ответ Bot API.
  response=$(printf '%s' "$body" | "$SSM_CURL" \
      --config /dev/fd/3 \
      --fail --silent --show-error \
      --connect-timeout 3 --max-time 12 \
      -X POST \
      -H 'Content-Type: application/x-www-form-urlencoded' \
      --data-binary @-) || {
    # Закрываем дескриптор с URL после неудачного запроса.
    exec 3<&-
    # Возвращаем ошибку отправки вызывающему скрипту.
    return 1
  }
  # Закрываем дескриптор с URL после успешного запроса.
  exec 3<&-

  # Разбираем JSON-ответ и требуем логическое поле `ok=true`.
  RESPONSE=$response python3 - <<'PY'
import json, os, sys
try:
    ok = json.loads(os.environ["RESPONSE"]).get("ok") is True
except Exception:
    ok = False
sys.exit(0 if ok else 1)
PY
}

Закрепите библиотеку за root, разрешите выполнение только владельцу и группе и запустите bash -n без исполнения кода. Нулевой статус последней команды подтверждает корректный синтаксис командной оболочки. Установите права и проверьте синтаксис:

# Закрепляем ожидаемого владельца и группу.
sudo chown root:root /usr/local/libexec/server-security-monitor/common.sh
# Устанавливаем минимально необходимые права доступа.
sudo chmod 0750 /usr/local/libexec/server-security-monitor/common.sh
# Проверяем синтаксис shell-скрипта без его выполнения.
sudo bash -n /usr/local/libexec/server-security-monitor/common.sh

В другой статье автора в этом же блоге — «Взаимодействие с Telegram Bot API через Postman» — показано, как вручную проверить sendMessage и увидеть JSON-ответ API.

Этот короткий исполняемый файл подключает библиотеку и отправляет сообщение с именем хоста. Он нужен для проверки всего пути — чтения закрытой конфигурации, HTTPS-запроса и проверки ответа API. Создайте тестовый отправщик /usr/local/libexec/server-security-monitor/send-test.sh:

#!/usr/bin/env bash
# Включаем строгую обработку ошибок и неинициализированных переменных.
set -Eeuo pipefail
# Ограничиваем поиск исполняемых файлов доверенными системными каталогами.
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# Подключаем доверенный файл с переменными или общими функциями.
. /usr/local/libexec/server-security-monitor/common.sh
# Отправляем сформированное сообщение через общую защищённую функцию.
tg_send "✅ <b>Тест мониторинга</b> на <code>$(hostname -f 2>/dev/null || hostname)</code>"

Назначьте тестовому отправщику исполняемые права только для root и его группы и запустите его на сервере. Успешный результат — полученное тестовое сообщение и нулевой код команды.

# Устанавливаем минимально необходимые права доступа.
sudo chmod 0750 /usr/local/libexec/server-security-monitor/send-test.sh
# Запускаем тестовый отправщик от root, чтобы он мог прочитать секреты.
sudo /usr/local/libexec/server-security-monitor/send-test.sh

Не переходите дальше, пока тестовое сообщение не пришло.


Часть V. AIDE и rkhunter

10. Инициализируем AIDE

Первая инициализация создаёт baseline — доверенную базовую запись контрольных сумм, владельцев, прав и других метаданных. Пути к рабочей и новой базе различаются по дистрибутивам; ожидаемый результат — установленная база с доступом только для root и чистая команда aide --check с кодом 0.

AIDE хранит контрольные суммы и метаданные файлов. Его база сама является критическим активом.

RHEL-подобные системы

Здесь aide --init создаёт новую базу, а install копирует её в рабочий путь с закрытыми правами. Проверка существования промежуточного файла не даёт принять пустой или неудачный результат.

# Проверяем конфигурацию, создаём baseline или сверяем текущее состояние AIDE.
sudo aide --config-check
# Проверяем конфигурацию, создаём baseline или сверяем текущее состояние AIDE.
sudo aide --init
# Убеждаемся, что AIDE действительно создал новую базу.
sudo test -f /var/lib/aide/aide.db.new.gz
# Создаём каталог или устанавливаем файл с заданными владельцем и правами.
sudo install -o root -g root -m 0600 \
  /var/lib/aide/aide.db.new.gz /var/lib/aide/aide.db.gz

Debian/Ubuntu

aideinit создаёт базу с учётом Debian-конфигурации, а update-aide.conf собирает итоговый конфиг из фрагментов. После этого проверка синтаксиса должна завершиться без сообщений об ошибке.

На Debian пути могут отличаться, а конфигурация может собираться из /etc/aide/aide.conf.d:

# Проверяем конфигурацию, создаём baseline или сверяем текущее состояние AIDE.
sudo aideinit
# Собираем итоговую конфигурацию AIDE из Debian-фрагментов.
sudo update-aide.conf
# Проверяем конфигурацию, создаём baseline или сверяем текущее состояние AIDE.
sudo aide --config-check

Команда читает только директивы путей базы из всех возможных AIDE-конфигов. Продолженная строка является одним grep; по её выводу нужно определить рабочую и новую базы именно вашей системы. Определите фактические пути из конфигурации:

# Извлекаем только нужные строки конфигурации или журнала.
sudo grep -RhsE '^(database|database_in|database_out)=' \
  /etc/aide.conf /etc/aide/aide.conf* /etc/aide/aide.conf.d 2>/dev/null

Запустите полное сравнение с только что созданной доверенной базой (baseline) и сразу выведите код завершения. На исходно чистой системе ожидается AIDE rc=0. Финальная проверка должна вернуть код 0:

# Проверяем конфигурацию, создаём baseline или сверяем текущее состояние AIDE.
sudo aide --check
# Формируем или выводим данные в заданном безопасном формате.
printf 'AIDE rc=%s\n' "$?"

Коды 1, 2 и 4 — битовая маска добавленных, удалённых и изменённых объектов. Их комбинации до 7 означают найденные изменения, а не обязательно поломку сканера.

11. Скрипт AIDE

Обёртка запускает проверку целостности по блокировке, сохраняет полный отчёт с правами 0600 и отправляет только краткое, экранированное резюме. Обычные коды AIDE о найденных изменениях трактуются как результат проверки, а настоящая ошибка сканера передаётся systemd ненулевым кодом.

Сначала создаём закрытый каталог отчётов и открываем новый файл скрипта через sudoedit. После блока должны существовать каталог 0700 и подготовленный к заполнению aide-check.sh.

# Создаём каталог или устанавливаем файл с заданными владельцем и правами.
sudo install -d -o root -g root -m 0700 \
  /var/log/server-security-monitor/aide
# Открываем файл обёртки AIDE через `sudoedit`.
sudoedit /usr/local/libexec/server-security-monitor/aide-check.sh

Сохраните следующий самостоятельный скрипт как aide-check.sh. Многострочные выражения grep, awk и текст Telegram комментируются перед началом: вставка комментариев внутрь кавычек или продолженных строк изменила бы данные и помешала безопасному копированию и вставке.

#!/usr/bin/env bash
# Включаем строгую обработку ошибок и неинициализированных переменных.
set -Eeuo pipefail
# Ограничиваем поиск исполняемых файлов доверенными системными каталогами.
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# Фиксируем локаль, чтобы парсинг вывода не зависел от языка системы.
export LC_ALL=C
# Подключаем доверенный файл с переменными или общими функциями.
. /usr/local/libexec/server-security-monitor/common.sh

# Находим AIDE или принимаем явно заданный путь.
AIDE_BIN=${AIDE_BIN:-$(command -v aide)}
# Задаём закрытый каталог полных отчётов.
LOG_ROOT=/var/log/server-security-monitor/aide
# Задаём lock-файл, исключающий параллельные запуски.
LOCK_FILE=/run/lock/server-security-aide.lock
# Ограничиваем число путей в коротком уведомлении.
MAX_PATHS=30

# Создаём защищённый каталог с явно заданными правами.
install -d -o root -g root -m 0700 "$LOG_ROOT"
# Открываем отдельный файловый дескриптор для lock-файла или закрытой передачи параметров.
exec 9>"$LOCK_FILE"
# Захватываем неблокирующую блокировку и не запускаем второй экземпляр.
flock -n 9 || exit 0

# Формируем временную метку для уникального имени отчёта.
stamp=$(date '+%Y%m%d-%H%M%S')
# Собираем полный путь нового отчёта.
report="$LOG_ROOT/aide-$stamp.log"

# Временно разрешаем ненулевой код сканера, чтобы сохранить и классифицировать результат.
set +e
# Запускаем AIDE и сохраняем полный вывод в закрытый отчёт.
"$AIDE_BIN" --check >"$report" 2>&1
# Сохраняем код завершения сканера до включения строгого режима.
rc=$?
# Включаем строгую обработку ошибок и неинициализированных переменных.
set -e
# Ограничиваем права созданного отчёта.
chmod 0600 "$report"

# Проверяем условие и завершаем шаг ранним безопасным результатом при необходимости.
[[ $rc -eq 0 ]] && exit 0

# Извлекаем числовую сводку из полного отчёта.
summary=$(grep -iE \
  'Total number of entries:[[:space:]]*[0-9]+|Added entries:[[:space:]]*[0-9]+|Removed entries:[[:space:]]*[0-9]+|Changed entries:[[:space:]]*[0-9]+' \
  "$report" | head -n 4 || true)

# Извлекаем ограниченный список изменённых путей.
paths=$(awk -v max="$MAX_PATHS" '
  /^[[:space:]]*(Added|Removed|Changed) entries:[[:space:]]*$/ {
    section=$0
    sub(/^[[:space:]]*/, "", section)
    next
  }
  section != "" && /:[[:space:]]+\// {
    if (!seen[section]++) print section
    print
    if (++count >= max) exit
  }
' "$report")

# Проверяем условие и завершаем шаг ранним безопасным результатом при необходимости.
[[ -n "$summary" ]] || summary="AIDE exit code: $rc"
# Проверяем условие и завершаем шаг ранним безопасным результатом при необходимости.
[[ -n "$paths" ]] || paths="Пути не извлечены; см. полный локальный отчёт."

# Экранируем сводку перед вставкой в HTML-сообщение.
summary_html=$(printf '%s' "$summary" | html_escape)
# Экранируем пути перед вставкой в HTML-сообщение.
paths_html=$(printf '%s' "$paths" | html_escape)
# Получаем полное имя хоста с запасным вариантом.
host=$(hostname -f 2>/dev/null || hostname)

# Проверяем условие и выбираем дальнейшую ветвь выполнения.
if (( rc <= 7 )); then
  # Выбираем заголовок уведомления по результату проверки.
  title='🧬 <b>AIDE: изменения файлов</b>'
# Если условие не выполнено, переходим к безопасной альтернативе.
else
  # Выбираем заголовок уведомления по результату проверки.
  title="🚨 <b>AIDE: ошибка проверки, код ${rc}</b>"
fi

# Отправляем сформированное сообщение через общую защищённую функцию.
tg_send "${title} на <code>${host}</code>
$(date '+%F %T %Z')
<pre>${summary_html}</pre>
<pre>${paths_html}</pre>
Полный отчёт: <code>${report}</code>"

# Изменения файлов — успешная доставка finding; ошибка сканера — ошибка unit.
(( rc <= 7 )) && exit 0
# Завершаем скрипт с выбранным кодом результата.
exit "$rc"

12. Скрипт rkhunter

Для rkhunter создаём отдельную обёртку с собственным журналом и flock, чтобы эвристическая проверка не пересекалась с другим запуском. Предупреждения попадут в Telegram и полный локальный отчёт, тогда как ошибки выполнения будут видны systemd по ненулевому статусу.

Сначала создаём отдельный каталог отчётов, доступный только root и открываем rkhunter-check.sh. Разделение каталогов упрощает ротацию и расследование результатов каждого сканера.

# Создаём каталог или устанавливаем файл с заданными владельцем и правами.
sudo install -d -o root -g root -m 0700 \
  /var/log/server-security-monitor/rkhunter
# Открываем файл обёртки rkhunter через `sudoedit`.
sudoedit /usr/local/libexec/server-security-monitor/rkhunter-check.sh

Сохраните следующий самостоятельный скрипт как rkhunter-check.sh. Многострочное сообщение и продолженная команда сканера поясняются перед началом логического шага, а не внутри создаваемого текста.

#!/usr/bin/env bash
# Включаем строгую обработку ошибок и неинициализированных переменных.
set -Eeuo pipefail
# Ограничиваем поиск исполняемых файлов доверенными системными каталогами.
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# Фиксируем локаль, чтобы парсинг вывода не зависел от языка системы.
export LC_ALL=C
# Подключаем доверенный файл с переменными или общими функциями.
. /usr/local/libexec/server-security-monitor/common.sh

# Находим rkhunter или принимаем явно заданный путь.
RKHUNTER_BIN=${RKHUNTER_BIN:-$(command -v rkhunter)}
# Задаём закрытый каталог полных отчётов.
LOG_ROOT=/var/log/server-security-monitor/rkhunter
# Задаём lock-файл, исключающий параллельные запуски.
LOCK_FILE=/run/lock/server-security-rkhunter.lock

# Создаём защищённый каталог с явно заданными правами.
install -d -o root -g root -m 0700 "$LOG_ROOT"
# Открываем отдельный файловый дескриптор для lock-файла или закрытой передачи параметров.
exec 9>"$LOCK_FILE"
# Захватываем неблокирующую блокировку и не запускаем второй экземпляр.
flock -n 9 || exit 0

# Формируем временную метку для уникального имени отчёта.
stamp=$(date '+%Y%m%d-%H%M%S')
# Собираем полный путь нового отчёта.
report="$LOG_ROOT/rkhunter-$stamp.log"

# Временно разрешаем ненулевой код сканера, чтобы сохранить и классифицировать результат.
set +e
# Запускаем rkhunter и сохраняем полный вывод в закрытый отчёт.
"$RKHUNTER_BIN" --cronjob --report-warnings-only --nocolors \
  >"$report" 2>&1
# Сохраняем код завершения сканера до включения строгого режима.
rc=$?
# Включаем строгую обработку ошибок и неинициализированных переменных.
set -e
# Ограничиваем права созданного отчёта.
chmod 0600 "$report"

# Проверяем условие и выбираем дальнейшую ветвь выполнения.
if [[ -s "$report" ]]; then
  # Берём короткий экранированный фрагмент отчёта.
  excerpt=$(head -n 35 "$report" | html_escape)
  # Получаем полное имя хоста с запасным вариантом.
  host=$(hostname -f 2>/dev/null || hostname)
  # Отправляем сформированное сообщение через общую защищённую функцию.
  tg_send "🦠 <b>rkhunter: предупреждения</b> на <code>${host}</code>
$(date '+%F %T %Z')
<pre>${excerpt}</pre>
Полный отчёт: <code>${report}</code>"
fi

# На распространённых сборках rc=1 сопровождает warnings; >=2 — ошибка запуска.
if (( rc >= 2 )); then
  # Проверяем условие и завершаем шаг ранним безопасным результатом при необходимости.
  [[ -s "$report" ]] || tg_send "🚨 <b>rkhunter: ошибка запуска</b>, код ${rc}"
  # Завершаем скрипт с выбранным кодом результата.
  exit "$rc"
fi
# Завершаем скрипт с выбранным кодом результата.
exit 0

Никогда не запускайте rkhunter --propupd автоматически. Эта команда объявляет текущее состояние доверенным. Сначала подтвердите происхождение изменений через пакетный менеджер и журнал обновлений.

13. Службы и таймеры systemd

Каждый сканер получает службу типа oneshot, которая выполняет одну проверку и завершается, и таймер, который запускает эту службу по расписанию. Случайная задержка распределяет нагрузку, Persistent=true догоняет пропущенный запуск после выключения сервера, а ограничения времени не дают зависшему сканеру работать бесконечно.

Сохраните файл службы ниже как /etc/systemd/system/aide-check.service. Он запускает одну проверку AIDE с низким приоритетом, закрытой маской прав и пределом 20 минут; после завершения нормальное состояние будет inactive, а результат — success. /etc/systemd/system/aide-check.service:

[Unit]
Description=AIDE file integrity check
After=local-fs.target network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/libexec/server-security-monitor/aide-check.sh
Nice=10
IOSchedulingClass=idle
UMask=0077
TimeoutStartSec=20min

Сохраните файл таймера рядом с файлом службы. Он планирует ежедневный запуск около 04:00, добавляет до 15 минут случайной задержки и выполняет пропущенную задачу после следующей загрузки. /etc/systemd/system/aide-check.timer:

[Unit]
Description=Daily AIDE integrity check

[Timer]
OnCalendar=*-*-* 04:00:00
RandomizedDelaySec=900
Persistent=true

[Install]
WantedBy=timers.target

Эта служба типа oneshot запускает обёртку rkhunter с теми же ограничениями, но даёт сканеру до 30 минут. Сохраните её в указанном пути под root:root. /etc/systemd/system/rkhunter-check.service:

[Unit]
Description=rkhunter rootkit scan
After=local-fs.target network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/libexec/server-security-monitor/rkhunter-check.sh
Nice=10
IOSchedulingClass=idle
UMask=0077
TimeoutStartSec=30min

Таймер запускает rkhunter около 04:30 со случайной задержкой и догоняет пропущенное расписание. Разнесённое время снижает вероятность одновременной нагрузки двух сканеров. /etc/systemd/system/rkhunter-check.timer:

[Unit]
Description=Daily rkhunter scan

[Timer]
OnCalendar=*-*-* 04:30:00
RandomizedDelaySec=900
Persistent=true

[Install]
WantedBy=timers.target

Команды закрепляют безопасные владельцев и режимы, проверяют Bash и службы systemd, перечитывают конфигурацию диспетчера и включают оба таймера. Продолженные списки файлов остаются едиными командами и комментируются только перед первой строкой. Установите права и активируйте:

# Закрепляем ожидаемого владельца и группу.
sudo chown root:root \
  /usr/local/libexec/server-security-monitor/*.sh \
  /etc/systemd/system/{aide-check,rkhunter-check}.{service,timer}
# Устанавливаем минимально необходимые права доступа.
sudo chmod 0750 /usr/local/libexec/server-security-monitor/*.sh
# Устанавливаем минимально необходимые права доступа.
sudo chmod 0644 /etc/systemd/system/{aide-check,rkhunter-check}.{service,timer}

# Проверяем синтаксис shell-скрипта без его выполнения.
sudo bash -n /usr/local/libexec/server-security-monitor/*.sh
# Валидируем systemd unit-файл до активации.
sudo systemd-analyze verify \
  /etc/systemd/system/aide-check.service \
  /etc/systemd/system/rkhunter-check.service
# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl daemon-reload
# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl enable --now aide-check.timer rkhunter-check.timer

Ручной запуск проверяет не только синтаксис, но и весь путь сканирования и уведомления. После завершения прочитайте Result, ExecMainStatus и свежий журнал обеих служб. Запустите реальные службы:

# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl start aide-check.service
# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl start rkhunter-check.service
# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl show aide-check.service rkhunter-check.service \
  -p Result -p ExecMainStatus
# Читаем относящиеся к сервису события systemd journal.
sudo journalctl -u aide-check.service -u rkhunter-check.service \
  --since '1 hour ago' --no-pager

Служба типа oneshot после успешного завершения обычно имеет состояние inactive, и это нормально. Важны Result=success и ExecMainStatus=0.


Часть VI. Мгновенные уведомления через PAM

14. Скрипты send-message и login-alert

Разделяем привилегированный обработчик PAM и простой отправщик: первый формирует безопасное сообщение из данных сессии, второй передаёт его общей библиотеке. Обработчик запускается отдельно и всегда возвращает успех, поэтому сбой Telegram не меняет результат SSH-входа или sudo.

PAM-переменные контролируются удалённым входом. Никогда не вставляйте их в eval, bash -c или создаваемую командную строку.

Сохраните первый блок как send-message.sh: это минимальный адаптер, который принимает уже сформированный текст одним аргументом и передаёт его общей функции. Строгий режим делает ошибку доставки видимой при прямом запуске. Сначала создайте /usr/local/libexec/server-security-monitor/send-message.sh:

#!/usr/bin/env bash
# Включаем строгую обработку ошибок и неинициализированных переменных.
set -Eeuo pipefail
# Ограничиваем поиск исполняемых файлов доверенными системными каталогами.
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# Подключаем доверенный файл с переменными или общими функциями.
. /usr/local/libexec/server-security-monitor/common.sh
# Отправляем сформированное сообщение через общую защищённую функцию.
tg_send "${1:-}"

Сохраните код как login-alert.sh: он обрабатывает только open_session, экранирует все PAM-поля и запускает отправщик в отдельной сессии. Многострочный текст msg и команда setsid поясняются целиком перед началом, чтобы не вставлять комментарии внутрь данных или строк с \. /usr/local/libexec/server-security-monitor/login-alert.sh:

#!/usr/bin/env bash
# Ошибка Telegram не должна блокировать login.
set -u
# Ограничиваем поиск исполняемых файлов доверенными системными каталогами.
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

# Проверяем условие и завершаем шаг ранним безопасным результатом при необходимости.
[[ ${PAM_TYPE:-} == open_session ]] || exit 0
# Подключаем доверенный файл с переменными или общими функциями.
. /usr/local/libexec/server-security-monitor/common.sh

# Выбираем подпись уведомления по имени PAM-сервиса.
case "${PAM_SERVICE:-unknown}" in
  # Для этой ветви задаём значок и понятное имя события.
  sshd) icon='🔐'; kind='SSH-вход' ;;
  # Для этой ветви задаём значок и понятное имя события.
  sudo) icon='🧨'; kind='sudo' ;;
  # Для этой ветви задаём значок и понятное имя события.
  su)   icon='🔀'; kind='su' ;;
  # Для этой ветви задаём значок и понятное имя события.
  *)    icon='👤'; kind=${PAM_SERVICE:-unknown} ;;
esac

# Задаём рабочее значение `user`.
user=$(printf '%s' "${PAM_USER:-?}" | html_escape)
# Задаём рабочее значение `source_host`.
source_host=$(printf '%s' "${PAM_RHOST:-local}" | html_escape)
# Задаём рабочее значение `service`.
service=$(printf '%s' "${PAM_SERVICE:-unknown}" | html_escape)
# Задаём рабочее значение `tty`.
tty=$(printf '%s' "${PAM_TTY:-?}" | html_escape)
# Получаем полное имя хоста с запасным вариантом.
host=$(hostname -f 2>/dev/null || hostname)

# Формируем текст уведомления из уже экранированных полей.
msg="${icon} <b>${kind}</b> на <code>${host}</code>
Пользователь: <code>${user}</code>
Источник: <code>${source_host:-local}</code>
Сервис/TTY: <code>${service} / ${tty}</code>
$(date '+%F %T %Z')"

# Данные передаются как один argv и никогда не вычисляются shell повторно.
/usr/bin/setsid -f \
  /usr/local/libexec/server-security-monitor/send-message.sh \
  "$msg" >/dev/null 2>&1 || true
# Завершаем скрипт с выбранным кодом результата.
exit 0

После сохранения обоих PAM-скриптов назначьте владельца, исполняемые права и проверьте синтаксис без запуска. Все три продолженные команды должны копироваться целиком.

# Закрепляем ожидаемого владельца и группу.
sudo chown root:root \
  /usr/local/libexec/server-security-monitor/{send-message,login-alert}.sh
# Устанавливаем минимально необходимые права доступа.
sudo chmod 0750 \
  /usr/local/libexec/server-security-monitor/{send-message,login-alert}.sh
# Проверяем синтаксис shell-скрипта без его выполнения.
sudo bash -n \
  /usr/local/libexec/server-security-monitor/{send-message,login-alert}.sh

15. Подключаем PAM безопасно

PAM — цепочка модулей аутентификации и управления сессиями; строка pam_exec.so добавит запуск нашего скрипта при открытии сессии. Перед правкой сохраняем рабочие файлы, подключаем обработчик как optional и проверяем новый вход отдельно, чтобы уведомление не стало единственной точкой отказа доступа.

Сначала держите открытой текущую root/sudo-сессию и проверьте наличие консоли провайдера.

# Сохраняем копию файла или дерева с исходными метаданными.
sudo cp -a /etc/pam.d/sshd "/etc/pam.d/sshd.bak.$(date +%s)"
# Сохраняем копию файла или дерева с исходными метаданными.
sudo cp -a /etc/pam.d/sudo "/etc/pam.d/sudo.bak.$(date +%s)"

Эта строка сохраняется именно в PAM-конфиге, а не выполняется в командной оболочке. session optional запускает уведомление при открытии сессии, но его отказ не меняет решение PAM о доступе. Добавьте в конец /etc/pam.d/sshd:

session optional pam_exec.so /usr/local/libexec/server-security-monitor/login-alert.sh

Опционально добавьте ту же строку в /etc/pam.d/sudo. Уведомления о каждом sudo могут быть шумными; на сервере с интенсивной автоматизацией лучше уведомлять только о SSH.

Ключевое слово должно быть optional, а не required: недоступность Telegram не должна лишать доступа к серверу.

Проверьте новый SSH-вход во второй сессии. Если он не работает, восстановите PAM-файл из резервной копии через текущую сессию или консоль.


Часть VII. Ежедневный дайджест

16. Скрипт security-digest

Ежедневный дайджест сводит в одно сообщение баны Fail2ban, решения CrowdSec, ошибки SSH, ресурсы узла, необходимость перезагрузки и состояние защитных служб. Для задач с таймером проверяется не только включённое расписание, но и результат последнего запуска службы типа oneshot; ожидаем короткий отчёт и успешный статус сервиса.

Сохраните блок как самостоятельный security-digest.sh. Встроенный Python находится внутри многострочной строки командной оболочки и разбирает JSON CrowdSec; комментарии внутри этого фрагмента были бы частью Python-кода, поэтому важные действия объяснены перед присваиванием. /usr/local/libexec/server-security-monitor/security-digest.sh:

#!/usr/bin/env bash
# Включаем строгую обработку ошибок и неинициализированных переменных.
set -Eeuo pipefail
# Ограничиваем поиск исполняемых файлов доверенными системными каталогами.
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# Фиксируем локаль, чтобы парсинг вывода не зависел от языка системы.
export LC_ALL=C
# Подключаем доверенный файл с переменными или общими функциями.
. /usr/local/libexec/server-security-monitor/common.sh

# Открываем отдельный файловый дескриптор для lock-файла или закрытой передачи параметров.
exec 9>/run/lock/server-security-digest.lock
# Захватываем неблокирующую блокировку и не запускаем второй экземпляр.
flock -n 9 || exit 0

# Получаем текущий статус SSH-jail Fail2ban.
f2b=$(fail2ban-client status sshd 2>/dev/null || true)
# Извлекаем число активных банов.
banned=$(printf '%s\n' "$f2b" | awk -F: '
  tolower($1) ~ /currently banned/ {gsub(/[^0-9]/,"",$2); print $2; exit}')
# Извлекаем общее число банов.
total=$(printf '%s\n' "$f2b" | awk -F: '
  tolower($1) ~ /total banned/ {gsub(/[^0-9]/,"",$2); print $2; exit}')

# Задаём типичное имя SSH-unit.
ssh_unit=sshd
# Проверяем фактическое состояние systemd-unit.
systemctl cat ssh.service >/dev/null 2>&1 && ssh_unit=ssh
# Считаем неудачные SSH-события за последние сутки.
fails=$(journalctl -u "$ssh_unit" --since '24 hours ago' --no-pager \
  2>/dev/null | grep -ciE \
  'Failed password|Invalid user|authentication failure' || true)

# Получаем решения CrowdSec в машиночитаемом JSON.
cs_json=$(cscli decisions list -o json 2>/dev/null || true)
# Подсчитываем решения с учётом разных форматов JSON.
cs=$(printf '%s' "$cs_json" | python3 -c '
import json, sys
try:
    data=json.load(sys.stdin)
    if isinstance(data, list):
        # Некоторые версии возвращают alerts с вложенными decisions.
        print(sum(len(x.get("decisions", [])) if isinstance(x, dict) else 0
                  for x in data))
    elif isinstance(data, dict):
        print(len(data.get("decisions", [])))
    else:
        print("?")
except Exception:
    print("?")')

# Собираем использование оперативной памяти.
mem=$(free -m | awk '/^Mem:/{print $3"/"$2" MB"}')
# Получаем заполнение корневой файловой системы.
disk=$(df -P / | awk 'NR==2{print $5}')
# Читаем значения load average непосредственно из `/proc/loadavg`.
read -r l1 l5 l15 _ </proc/loadavg

# Проверяем условие и выбираем дальнейшую ветвь выполнения.
if command -v needs-restarting >/dev/null 2>&1; then
  # Проверяем условие и выбираем дальнейшую ветвь выполнения.
  if needs-restarting -r >/dev/null 2>&1; then
    # Формируем понятный статус необходимости перезагрузки.
    reboot_state='✅ ребут не нужен'
  # Если условие не выполнено, переходим к безопасной альтернативе.
  else
    # Формируем понятный статус необходимости перезагрузки.
    reboot_state='♻️ <b>НУЖЕН РЕБУТ</b>'
  fi
# Если предыдущее условие ложно, проверяем альтернативный признак.
elif [[ -e /var/run/reboot-required ]]; then
  # Формируем понятный статус необходимости перезагрузки.
  reboot_state='♻️ <b>НУЖЕН РЕБУТ</b>'
# Если условие не выполнено, переходим к безопасной альтернативе.
else
  # Формируем понятный статус необходимости перезагрузки.
  reboot_state='✅ ребут не требуется или не определён'
fi

# Объявляем проверку timer и результата последнего oneshot-запуска.
check_timer() {
  # Задаём рабочее значение `timer`.
  local timer=$1 service=$2
  # Проверяем фактическое состояние systemd-unit.
  systemctl is-active --quiet "$timer" &&
  systemctl is-enabled --quiet "$timer" &&
  [[ $(systemctl show "$service" -p Result --value 2>/dev/null) == success ]] &&
  [[ $(systemctl show "$service" -p ExecMainStatus --value 2>/dev/null) == 0 ]]
}

# Создаём массив результатов самопроверки защитных сервисов.
health=()
# Добавляем в дайджест результат проверки AIDE timer и сервиса.
check_timer aide-check.timer aide-check.service \
  && health+=('AIDE✅') || health+=('AIDE⚠️')
# Добавляем в дайджест результат проверки rkhunter timer и сервиса.
check_timer rkhunter-check.timer rkhunter-check.service \
  && health+=('rkhunter✅') || health+=('rkhunter⚠️')
# Проверяем фактическое состояние systemd-unit.
systemctl is-active --quiet crowdsec crowdsec-firewall-bouncer \
  && health+=('CrowdSec✅') || health+=('CrowdSec⚠️')
# Проверяем фактическое состояние systemd-unit.
systemctl is-active --quiet fail2ban \
  && health+=('Fail2ban✅') || health+=('Fail2ban⚠️')

# Получаем полное имя хоста с запасным вариантом.
host=$(hostname -f 2>/dev/null || hostname)
# Отправляем сформированное сообщение через общую защищённую функцию.
tg_send "📋 <b>Дайджест ${host}</b> — $(date '+%F')
🔐 SSH забанено: ${banned:-?} (всего ${total:-?})
🛡 CrowdSec активных решений: ${cs:-?}
❌ Неудачных SSH за сутки: ${fails:-0}
🧠 RAM: ${mem} | 💾 Диск /: ${disk}
📈 load: ${l1}, ${l5}, ${l15}
${reboot_state}
🩺 ${health[*]}"

Эта служба типа oneshot запускает сбор дайджеста после появления сети и защитных сервисов, ограничивает права создаваемых файлов и даёт задаче две минуты. Сохраните его в указанном системном пути. Служба /etc/systemd/system/security-digest.service:

[Unit]
Description=Daily security digest to Telegram
After=network-online.target crowdsec.service fail2ban.service
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/libexec/server-security-monitor/security-digest.sh
Nice=10
IOSchedulingClass=idle
UMask=0077
TimeoutStartSec=2min

Таймер планирует дайджест примерно на 09:00, добавляет случайную задержку и догоняет пропущенный запуск. После включения он должен появиться в systemctl list-timers. Таймер /etc/systemd/system/security-digest.timer:

[Unit]
Description=Daily security digest to Telegram

[Timer]
OnCalendar=*-*-* 09:00:00
RandomizedDelaySec=900
Persistent=true

[Install]
WantedBy=timers.target

Сначала назначьте скрипту исполняемые права и проверьте службу, затем перечитайте конфигурацию systemd, включите таймер и вручную запустите сервис. Итоговый статус должен быть успешным, а расписание — видимым в списке таймеров. Активируйте и проверьте реальным запуском:

# Устанавливаем минимально необходимые права доступа.
sudo chmod 0750 /usr/local/libexec/server-security-monitor/security-digest.sh
# Валидируем systemd unit-файл до активации.
sudo systemd-analyze verify /etc/systemd/system/security-digest.service
# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl daemon-reload
# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl enable --now security-digest.timer
# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl start security-digest.service
# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl show security-digest.service -p Result -p ExecMainStatus
# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl list-timers --all | grep security-digest

Часть VIII. Обновления и ротация

17. Автоматические обновления безопасности

Автоматизируем только установку обновлений безопасности, сохраняя перезагрузку и принятие новой доверенной базы AIDE (baseline) под ручным контролем. В Debian/Ubuntu этим занимается unattended-upgrades, в RHEL-подобных системах — таймер dnf-automatic-install; сухой запуск или список таймеров должен подтвердить рабочую настройку.

Debian/Ubuntu

Диалог dpkg-reconfigure включает штатный механизм автоматических обновлений, после чего служба запускается немедленно. Сухой запуск показывает выбранные источники и пакеты без изменения системы; ожидаем отсутствие ошибок и только разрешённые репозитории обновлений безопасности.

# Настраиваем автоматические обновления безопасности Debian/Ubuntu.
sudo dpkg-reconfigure --priority=low unattended-upgrades
# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl enable --now unattended-upgrades
# Выполняем подробный сухой запуск без установки обновлений.
sudo unattended-upgrade --dry-run --debug

Проверьте /etc/apt/apt.conf.d/50unattended-upgrades и убедитесь, что включены только ожидаемые источники. Автоматическую перезагрузку по умолчанию оставьте выключенной; дайджест сообщит о /var/run/reboot-required.

RHEL-подобные

В секции [commands] оставьте установку только обновлений безопасности и запретите автоматическую перезагрузку. Эти строки сохраняются в INI-файле, а не выполняются в командной оболочке. В /etc/dnf/automatic.conf:

[commands]
upgrade_type = security
download_updates = yes
apply_updates = yes
reboot = never

Включите штатный таймер DNF и убедитесь, что systemd показывает его расписание. Это подтверждает автоматический запуск, но необходимость перезагрузки по-прежнему контролирует дайджест.

# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl enable --now dnf-automatic-install.timer
# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl list-timers --all | grep dnf-automatic

Не обновляйте AIDE-базу автоматически сразу после установки пакетов. Сначала сопоставьте изменения с журналом пакетного менеджера.

18. logrotate

logrotate ограничивает рост локальных отчётов: ежедневно архивирует их, хранит 30 поколений и создаёт новые файлы с правами 0600. Проверка в режиме отладки не выполняет ротацию, но показывает, что правило читается и выбирает ожидаемые журналы с доступом только для root.

Сохраните правило в указанном файле: шаблоны охватывают отчёты обоих сканеров, а su и create сохраняют владельца и закрытые права. После 30 ротаций старые архивы будут удаляться. /etc/logrotate.d/server-security-monitor:

/var/log/server-security-monitor/aide/*.log /var/log/server-security-monitor/rkhunter/*.log {
    daily
    rotate 30
    compress
    delaycompress
    missingok
    notifempty
    su root root
    create 0600 root root
}

Обе команды безопасны для работающего сервера: первая разбирает правило без изменения файлов, вторая показывает фактические режимы и владельцев отчётов. Ожидаются только файлы 0600 root:root. Проверка без ротации:

# Проверяем правило logrotate в режиме отладки без ротации.
sudo logrotate -d /etc/logrotate.d/server-security-monitor
# Проверяем права, владельцев, размеры и пути локальных отчётов.
sudo find /var/log/server-security-monitor -type f \
  -printf '%m %u:%g %s %p\n'

Все отчёты должны быть 0600 root:root.


Часть IX. Итоговая проверка

19. Итоговая проверка

Теперь проверяем собранную систему как единое целое, а не наличие отдельных пакетов. Команды сверяют эффективную конфигурацию, готовность демонов, применённые решения, результаты последних запусков, расписания и права на секреты; все проверки должны пройти без скрытых токенов и неуспешных сервисов.

SSH и межсетевой экран

Проверяем синтаксис и фактически применённые параметры sshd, затем слушающие сокеты и постоянные правила firewalld. Это должно подтвердить, что усиленный SSH доступен только на ожидаемом порту.

# Проверяем синтаксис или эффективную конфигурацию sshd.
sudo sshd -t
# Проверяем синтаксис или эффективную конфигурацию sshd.
sudo sshd -T | grep -E \
  '^(port|permitrootlogin|passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication|maxauthtries) '
# Проверяем, что sshd действительно слушает ожидаемый TCP-порт.
sudo ss -ltnp
# Изменяем или проверяем постоянное правило firewalld.
sudo firewall-cmd --list-all

Ожидается:

  • прямой вход root запрещён;
  • пароли и интерактивная аутентификация с клавиатуры выключены;
  • публичные ключи включены;
  • межсетевой экран содержит только необходимые службы и фактический SSH-порт.

Fail2ban

Клиент должен ответить pong, показать активный jail sshd и пройти повторную проверку конфигурации. Так мы проверяем готовность процесса, а не только состояние службы.

# Проверяем конфигурацию, готовность или состояние Fail2ban.
sudo fail2ban-client ping
# Проверяем конфигурацию, готовность или состояние Fail2ban.
sudo fail2ban-client status
# Проверяем конфигурацию, готовность или состояние Fail2ban.
sudo fail2ban-client status sshd
# Проверяем конфигурацию, готовность или состояние Fail2ban.
sudo fail2ban-client -t

CrowdSec

Сверяем работу механизма обнаружения и bouncer, поступление событий, список решений и регистрацию исполнителя. Наличие таблиц CrowdSec в nftables подтверждает, что решения могут влиять на трафик.

# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl is-active crowdsec crowdsec-firewall-bouncer
# Управляем объектами CrowdSec или читаем их фактическое состояние.
sudo cscli metrics
# Управляем объектами CrowdSec или читаем их фактическое состояние.
sudo cscli decisions list
# Управляем объектами CrowdSec или читаем их фактическое состояние.
sudo cscli bouncers list
# Просматриваем фактические правила nftables.
sudo nft list ruleset | grep -i crowdsec

Мониторинг

Принудительно запускаем все три службы типа oneshot и читаем их итоговые статусы, после чего проверяем включённые таймеры и ближайшие запуски. Нормальный результат — Result=success, ExecMainStatus=0 и три активных расписания.

# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl start aide-check.service
# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl start rkhunter-check.service
# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl start security-digest.service

# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl show \
  aide-check.service rkhunter-check.service security-digest.service \
  -p Result -p ExecMainStatus

# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl is-enabled \
  aide-check.timer rkhunter-check.timer security-digest.timer
# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl list-timers --all | grep -E \
  'aide-check|rkhunter-check|security-digest'

Файлы и секреты

Права должны ограничивать чтение конфигурации Telegram и отчётов, а поиск по форме токена — не находить совпадений в скриптах и файлах служб. Команда выводит только метаданные и пути, но не значения секретов.

# Выводим только права, владельца и имя защищённого файла.
sudo stat -c '%A %U:%G %n' \
  /etc/server-security-monitor/telegram.env \
  /usr/local/libexec/server-security-monitor/*.sh \
  /var/log/server-security-monitor

# Извлекаем только нужные строки конфигурации или журнала.
sudo grep -RIlE \
  '[0-9]{6,12}:[A-Za-z0-9_-]{20,}' \
  /usr/local/libexec/server-security-monitor \
  /etc/systemd/system || true

Последняя команда не должна находить токены Telegram-бота в скриптах или файлах служб.

20. Негативные тесты

Штатная проверка не показывает поведение при отказе, поэтому намеренно имитируем недоступный Telegram-конфиг и занятый файл блокировки. Ожидаем контролируемую ошибку отправщика без блокировки PAM и тихий отказ второго экземпляра AIDE без параллельного сканирования.

Проверьте отказ Telegram, временно указав несуществующий конфиг через переменную только для тестовой команды:

# Подменяем путь к конфигу только для этого запуска, имитируя отказ.
sudo SSM_CONF=/nonexistent \
  /usr/local/libexec/server-security-monitor/send-test.sh

Команда должна завершиться ошибкой, но вход через SSH/PAM не должен блокироваться.

Первая команда удерживает файл блокировки 15 секунд в фоне, вторая в это время запускает службу AIDE. Обёртка должна увидеть занятую блокировку и завершиться без второго сканера. Проверьте блокировку параллельного запуска:

# Удерживаем AIDE lock в фоновом процессе в течение 15 секунд.
sudo flock /run/lock/server-security-aide.lock sleep 15 &
# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl start aide-check.service

Второй запуск должен тихо завершиться, не создавая параллельную тяжёлую проверку.

Не создавайте тестовое изменение в системном файле. Для AIDE используйте отдельный заранее включённый тестовый путь, например /root/aide-test, затем удалите его и исследуйте оба обнаруженных изменения. После теста база не обновляется без ручной проверки.


Часть X. Расследование и обслуживание

21. Что делать при тревоге AIDE

Тревога AIDE сообщает о расхождении с доверенной базой, но не объясняет причину. Сначала сохраняем доказательства и устанавливаем владельца файла, пакет, расширенные права (capabilities), контекст SELinux и связь с обновлениями; доверенную базу (baseline) меняем только после подтверждения всех путей.

  1. Не запускайте aide --update.
  2. Сохраните полный отчёт и время события. Для каждого пути набор проверок без изменения системы ниже показывает метаданные, пакетного владельца, расширенные права и контекст SELinux. Отсутствие пакета или отдельной команды допускается, поэтому диагностические строки используют || true.
  3. Для каждого пути выполните:
# Выводим только права, владельца и имя защищённого файла.
sudo stat /PATH/TO/FILE
# Проверяем принадлежность объекта RPM-пакету.
sudo rpm -qf /PATH/TO/FILE 2>/dev/null || true
# Проверяем принадлежность объекта Debian-пакету.
sudo dpkg -S /PATH/TO/FILE 2>/dev/null || true
# Читаем назначенные файлу Linux capabilities.
sudo getcap /PATH/TO/FILE 2>/dev/null || true
# Показываем права, владельца и SELinux-контекст объекта.
sudo ls -lZ /PATH/TO/FILE 2>/dev/null || true

Следующий блок читает последнюю транзакцию DNF или историю APT без изменения системы. Совпадающее время и ожидаемый пакет помогают объяснить изменение, но сами по себе не заменяют проверку файла.

  1. Сопоставьте время с обновлениями:
# Показываем детали последней транзакции DNF.
sudo dnf history info last 2>/dev/null || true
# Извлекаем только нужные строки конфигурации или журнала.
sudo grep -hE ' upgrade | install | remove ' /var/log/apt/history.log 2>/dev/null || true
  1. Проверьте журналы SSH, sudo и системный журнал за время вокруг изменения.
  2. Если изменение ожидаемое, создайте резервную копию старой базы и выполните обновление вручную.
  3. Если происхождение неизвестно, не уничтожайте доказательства: ограничьте сеть, сохраните журналы и расследуйте с доверенного хоста.

Безопасное обновление AIDE-базы

Обновление доверенной базы (baseline) допустимо только после расследования: сначала повторяем проверку, создаём новую базу, сохраняем старую и устанавливаем новую с правами 0600. Итоговая проверка без изменений подтверждает, что принята именно рассмотренная версия состояния.

Пути различаются по дистрибутивам. Для RHEL-подобного варианта:

# Проверяем конфигурацию, создаём baseline или сверяем текущее состояние AIDE.
sudo aide --check
# Проверяем конфигурацию, создаём baseline или сверяем текущее состояние AIDE.
sudo aide --update
# Проверяем, что обновление AIDE создало новую базу перед её установкой.
sudo test -f /var/lib/aide/aide.db.new.gz
# Сохраняем копию файла или дерева с исходными метаданными.
sudo cp -a /var/lib/aide/aide.db.gz \
  "/var/lib/aide/aide.db.gz.bak.$(date +%s)"
# Создаём каталог или устанавливаем файл с заданными владельцем и правами.
sudo install -o root -g root -m 0600 \
  /var/lib/aide/aide.db.new.gz /var/lib/aide/aide.db.gz
# Проверяем конфигурацию, создаём baseline или сверяем текущее состояние AIDE.
sudo aide --check

Последняя команда обязана вернуть 0. Не перенаправляйте отчёт об обновлении внутрь каталога, который AIDE проверяет в том же запуске: это создаёт самопорождающиеся изменения.

22. rkhunter и ложные срабатывания

Предупреждение rkhunter — повод сопоставить объект с пакетами и журналами, а не автоматически объявлять компрометацию. Отдельно исключаем двойное расписание, когда пакетное задание cron и наш таймер systemd запускают один сканер и создают лишнюю нагрузку или локальную почтовую очередь.

rkhunter — эвристический инструмент, а не доказательство компрометации. Проверяйте:

  • принадлежность файла пакету;
  • изменения после обновления;
  • права, владельца, расширенные права (capabilities) и контекст SELinux;
  • наличие дублирующего расписания.

Некоторые пакеты устанавливают /etc/cron.daily/rkhunter, а вы дополнительно создаёте таймер systemd. Это приводит к двойному запуску и иногда к очереди локальной почты. Блок показывает наличие пакетного задания cron, включённость созданного нами таймера и результат его последнего сервиса; так можно доказать дублирование до отключения одного расписания. Некоторые пакеты устанавливают /etc/cron.daily/rkhunter, а вы дополнительно создаёте таймер systemd. Это приводит к двойному запуску и иногда к очереди локальной почты. Проверьте:

# Проверяем, запускает ли пакетный cron ещё одну копию rkhunter.
sudo run-parts --test /etc/cron.daily | grep -i rkhunter || true
# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl is-enabled rkhunter-check.timer
# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl show rkhunter-check.service -p Result -p ExecMainStatus

Если созданный нами таймер полностью заменяет пакетное задание cron, используйте документированный параметр отключения пакета. Если его нет, допустимо снять право на исполнение только с конкретного файла cron, записав способ отката. Обновление пакета может вернуть это право.

23. Ежемесячное обслуживание

Раз в месяц повторно валидируем конфигурацию доступа, обновляем индекс сценариев CrowdSec и просматриваем ошибки systemd, расписания и права отчётов. Такой контроль обнаруживает дрейф после обновлений пакетов; он не применяет новые сценарии CrowdSec автоматически и не принимает изменения AIDE.

# Проверяем синтаксис или эффективную конфигурацию sshd.
sudo sshd -t
# Проверяем конфигурацию, готовность или состояние Fail2ban.
sudo fail2ban-client -t
# Управляем объектами CrowdSec или читаем их фактическое состояние.
sudo cscli hub update
# Управляем объектами CrowdSec или читаем их фактическое состояние.
sudo cscli collections list
# Управляем объектами CrowdSec или читаем их фактическое состояние.
sudo cscli metrics
# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl --failed
# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl list-timers --all
# Читаем относящиеся к сервису события systemd journal.
sudo journalctl -p 0..3 --since '30 days ago'
# Проверяем права, владельцев, размеры и пути локальных отчётов.
sudo find /var/log/server-security-monitor -type f \
  -printf '%m %u:%g %TY-%Tm-%Td %p\n'

Не выполняйте автоматический cscli hub upgrade без чтения примечаний к выпуску и резервной копии конфигурации.


Часть XI. Откат и удаление

24. Быстрый откат SSH/PAM

Откат — возврат к заранее сохранённой рабочей конфигурации. Его выполняют из ещё открытой привилегированной сессии или консоли провайдера: восстанавливают SSH и PAM, проверяют синтаксис и только затем перечитывают конфигурацию демона.

Выполните блок из канала, который не зависит от исправляемой SSH-сессии. Он возвращает сохранённые деревья на место, проверяет sshd и только после успеха перечитывает конфигурацию. Через открытую аварийную сессию или консоль провайдера:

# Сохраняем копию файла или дерева с исходными метаданными.
sudo cp -a "$BACKUP/ssh/." /etc/ssh/
# Сохраняем копию файла или дерева с исходными метаданными.
sudo cp -a "$BACKUP/pam.d/." /etc/pam.d/
# Проверяем синтаксис или эффективную конфигурацию sshd.
sudo sshd -t
# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl reload sshd 2>/dev/null || sudo systemctl reload ssh

Если переменная BACKUP уже потеряна, найдите каталог вручную в /root/server-security-backup-*.

25. Отключение слоя мониторинга

Удаление выполняем от расписаний к файлам: сначала останавливаем таймеры, затем убираем службы и скрипты, отдельно решаем судьбу секретов и отчётов и в конце проверяем межсетевой экран. Такой порядок не оставляет задач systemd, ссылающихся на уже удалённые файлы, и не удаляет доказательства раньше времени.

# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl disable --now \
  aide-check.timer rkhunter-check.timer security-digest.timer
# Удаляем unit- и timer-файлы отключённого monitoring-слоя.
sudo rm -f /etc/systemd/system/{aide-check,rkhunter-check,security-digest}.{service,timer}
# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl daemon-reload

Перед удалением обработчика PAM сначала удалите только строку с server-security-monitor/login-alert.sh, проверьте новый SSH-вход и лишь затем удаляйте скрипты. Следующий блок удаляет общую библиотеку и правило ротации, поэтому после него уведомления и плановое обслуживание этих журналов прекратятся.

# Удаляем библиотеку и скрипты monitoring-слоя.
sudo rm -rf /usr/local/libexec/server-security-monitor
# Удаляем больше не используемое правило ротации отчётов.
sudo rm -f /etc/logrotate.d/server-security-monitor

Не удаляйте /etc/server-security-monitor/telegram.env и отчёты, пока не подтвердили, что они больше не нужны. Уничтожайте секреты осознанно:

# Удаляем локальный файл Telegram-секретов после подтверждения.
sudo rm -f /etc/server-security-monitor/telegram.env

Перед пакетным удалением остановите bouncer, механизм обнаружения и Fail2ban, затем сохраните вывод фактических правил nftables и firewalld. Результат нужен, чтобы убедиться, что остановка исполнителей не закрыла административный порт и не оставила неожиданные правила. CrowdSec и Fail2ban удаляйте штатным пакетным менеджером только после остановки bouncer и проверки межсетевого экрана:

# Управляем systemd-unit или читаем его фактическое состояние.
sudo systemctl disable --now crowdsec-firewall-bouncer crowdsec fail2ban
# Просматриваем фактические правила nftables.
sudo nft list ruleset
# Изменяем или проверяем постоянное правило firewalld.
sudo firewall-cmd --list-all

Как проверить итоговую настройку

Список ниже описывает ожидаемое состояние вашего сервера после выполнения руководства. Сверьте каждый пункт: так вы убедитесь, что настройки не просто сохранены в файлах, а действительно применяются, проверки запускаются по расписанию, а результаты и уведомления доступны для последующего расследования.

  • SELinux работает в режиме Enforcing, если он используется в выбранном дистрибутиве;
  • SSH слушает только выбранный нестандартный порт, а PermitRootLogin no, PasswordAuthentication no, KbdInteractiveAuthentication no и MaxAuthTries 3 действуют в итоговой конфигурации;
  • firewalld активен и открывает только заявленные веб-службы и фактический SSH-порт;
  • Fail2ban обслуживает sshd и recidive, использует журнал systemd и числовой SSH-порт;
  • механизм обнаружения CrowdSec и bouncer межсетевого экрана активны, а bouncer применяет решения через nftables;
  • проверки AIDE и rkhunter, ежедневный отчёт о состоянии и обновления безопасности запускаются таймерами systemd со случайной задержкой и Persistent=true;
  • последние запуски служб типа oneshot имеют Result=success и ExecMainStatus=0;
  • конфигурация Telegram имеет права 0600 root:root;
  • локальные отчёты AIDE и rkhunter сохраняются с правами 0600 root:root и ротируются;
  • ежедневный отчёт и уведомления PAM используют ограниченные сетевые тайм-ауты;
  • рабочая база AIDE существует, а временная новая база не остаётся после принятия проверенной доверенной базы (baseline).

Отдельно проверьте источник исполняемого файла crowdsec-firewall-bouncer. Это нужно, чтобы понимать, какой пакет отвечает за его обновление и происхождение. Из двух команд ниже только менеджер пакетов вашего дистрибутива должен вернуть один подходящий пакет; вторая команда ожидаемо не найдёт владельца. Если обе команды ничего не возвращают, исполнитель установлен вне пакетного менеджера и его обновление нужно контролировать отдельно.

# Проверяем, принадлежит ли bouncer установленному RPM-пакету.
rpm -qf "$(command -v crowdsec-firewall-bouncer)" 2>/dev/null || true
# Проверяем, принадлежит ли bouncer установленному Debian-пакету.
dpkg -S "$(command -v crowdsec-firewall-bouncer)" 2>/dev/null || true

Итог

После настройки сервер не становится неуязвимым, но система последовательно уменьшает вероятность успешной атаки, обнаруживает подозрительную активность, блокирует известные источники атак и сообщает администратору о событиях, которые нужно проверить:

  • SSH принимает вход по ключам, а прямой вход root и парольная аутентификация отключены;
  • межсетевой экран оставляет доступными только необходимые службы и фактический SSH-порт;
  • Fail2ban автоматически блокирует повторные неудачные попытки входа;
  • CrowdSec обнаруживает более широкие сценарии атак, а bouncer применяет решения о блокировке;
  • AIDE выявляет изменения важных файлов, а rkhunter добавляет отдельный эвристический сигнал;
  • обновления безопасности сокращают время, в течение которого сервер остаётся уязвимым для известных проблем;
  • Telegram доставляет краткие уведомления, а полные данные расследования сохраняются локально;
  • таймеры systemd, блокировки файлов, тайм-ауты и проверка кодов завершения помогают вовремя заметить сбой самой системы мониторинга.

Главное — не просто установить компоненты, а регулярно проверять, что они действительно работают: слушают правильные порты, применяют решения, успешно завершают проверки, сохраняют отчёты и доставляют уведомления без раскрытия секретов.

Официальные источники

A practical guide for Debian/Ubuntu and RHEL-like systems

Article version: 1.0, 7 August 2026. The solution is based on a real working CentOS Stream 9 configuration and has been re-checked against its actual state. All addresses, keys, tokens, chat identifiers and other secrets have been removed.

What we are building

In this guide we assemble a layered protection system for a Linux server. Its components solve different tasks: they restrict network access, protect SSH, detect and block attacks, monitor changes to important files and inform the administrator about events that require attention. The server keeps working on its own: Telegram is used for notifications, while the full reports stay on the server itself.

After the setup the server accepts only the network traffic its services need. SSH allows key-based login through a separate administrative account; root login and passwords are disabled. Repeated guessing attempts are blocked automatically, more complex attack scenarios are detected by CrowdSec, the integrity of system files is monitored by AIDE, and security updates are installed on a schedule. All these mechanisms run on the server itself and do not require the constant involvement of the administrator.

The administrator, meanwhile, sees what is happening with the protection and learns about the events that need to be checked:

  • an immediate notification arrives right after an SSH login or a use of sudo;
  • after a scheduled AIDE or rkhunter scan an alert is sent if changes or warnings are found;
  • once a day a short report arrives about bans, failed SSH attempts, memory, disk, load average, the need for a reboot and the state of the protective services.

If Telegram is unavailable, SSH login is not blocked. The scheduled checks continue, and their reports are stored locally with 0600 root:root permissions. This is an important part of the design: the alerting channel may break, but a notification failure must not turn into a server outage or destroy the data needed for an investigation.

How the protection is arranged

The first layer is the firewall. It allows only the ports the server actually serves: for example, HTTPS and the chosen SSH port. Everything else is dropped before the request reaches the application. In this solution firewalld manages the nftables rules.

Behind the firewall runs a hardened SSH:

  • root login is forbidden;
  • password and keyboard-interactive authentication are disabled;
  • access is granted by public keys;
  • the number of authentication attempts is limited;
  • administrative work is done through a separate user and sudo;
  • a non-standard port reduces the background noise in the logs, although on its own it does not count as protection.

Fail2ban reads the SSH log and reacts to repeated errors coming from the same address. After several failed attempts it adds a temporary rule to the firewall. A separate jail — a set of blocking rules for one particular source of events — named recidive can block repeat offenders for longer. A crucial detail: Fail2ban must know the actual numeric SSH port. The value port = ssh usually means port 22 and is not suitable if sshd has been moved, for example, to 2207.

CrowdSec solves a different task. It analyses SSH, system and web logs with ready-made scenarios, takes the collective reputation of addresses into account and creates blocking decisions. The detection mechanism itself (Security Engine) only detects events. For the decisions to affect traffic, a firewall bouncer is installed — a separate enforcer that receives them through the local API and applies them through nftables. Fail2ban and CrowdSec partly overlap, but they work on different data: Fail2ban is good at stopping simple local password guessing, while CrowdSec recognises more scenarios and uses a shared base of observations.

What happens after a successful login

SSH stays under the control of PAM. When a new session is opened, a small script owned by root receives the user name, the remote address, the service and the TTY, escapes these values and sends a message to Telegram. The same hook — an event handler — can be attached to sudo if the administrator needs notifications about privilege escalation.

The PAM handler is declared as optional. It starts the delivery separately and always lets the login process continue. The text coming from PAM is passed to the script as data and is not inserted into eval or into a dynamically built shell command. This protects against command injection and does not make access to the server depend on the Telegram API.

How files and the state of the system are monitored

AIDE builds a database of checksums, owners, permissions and other metadata for the selected files. The daily check compares the current state with the database. If a file is added, removed or changed, Telegram receives a short summary and the first paths, while the full report is written to a directory available only to root. The database is not updated automatically: otherwise an attacker or a faulty update could quietly become the new “normal” state.

rkhunter runs separately and looks for known signs of rootkits, suspicious permissions and unusual system objects. Its results require human review: this tool is heuristic and may react to legitimate updates. The rkhunter --propupd command is also not run automatically, because it declares the current state trusted.

Both checks are started at night by systemd timers with a random delay. flock takes a lock on a file and does not let two copies of a heavy scanner work at the same time, TimeoutStartSec limits a hung run in time, and logrotate compresses and deletes old reports according to the configured policy.

How the system reports its own failures

Monitoring is only useful when it watches not only the server but also itself. That is why the daily digest does not stop at checking that a timer is enabled. For every service of the oneshot type, that is, one that performs a single task and then exits, it reads the Result and ExecMainStatus of the last run. This makes it possible to tell a normally finished AIDE check from a timer that is formally active but has been starting a failing script for several days.

The Telegram bot token and the chat identifier are stored in a separate file available only to root. The shared sending function sets short network timeouts, checks the HTTP response and the ok field in the JSON, limits the length of the message and escapes HTML. Neither the secrets nor the message text are passed in the arguments of the curl process.

Automatic security updates close known vulnerabilities through unattended-upgrades or dnf-automatic. Automatic reboot is switched off: if a new kernel or library requires a reboot, this goes into the daily digest. The AIDE database is not accepted automatically after a package update. The administrator first compares the changes with the package manager log and only then updates the trusted database (baseline) by hand.

What the work of the system looks like on an ordinary day

During normal operation the server hardly disturbs the administrator. Fail2ban applies local bans, and the firewall bouncer applies the CrowdSec decisions. Clean AIDE and rkhunter checks send no alerts. Once a day a short status report arrives. The full technical logs stay on the server and are rotated.

If SSH guessing starts, Fail2ban blocks the source after the configured number of errors. CrowdSec may independently create a broader decision. If someone logs in successfully, the administrator immediately sees the user and the source of the connection. If system files change after an update or a break-in, AIDE shows the specific paths. If a scanner, the Telegram API or a systemd service breaks, the error becomes visible through the service status and the digest instead of being lost silently.

The diagram below shows the path of a network event through the preventive measures and the detection tools, as well as the separate loop of local checks and notifications. It shows which component blocks traffic, which one only detects an event and where the detailed data is stored.

Интернет
   │
   ├── firewalld / nftables ───────────── разрешает только нужные порты
   │
   ├── Fail2ban ───────────────────────── банит повторные ошибки SSH
   │
   ├── CrowdSec + firewall bouncer ───── сценарии атак и репутация адресов
   │
   └── sshd ───────────────────────────── ключи, запрет root и паролей
          │
          └── PAM ─────────────────────── уведомление о входе и sudo

Файловая система ── AIDE ─────────────── контроль целостности
Хост ────────────── rkhunter ──────────── дополнительная эвристика
Пакеты ──────────── unattended-upgrades /
                     dnf-automatic ────── security-обновления

systemd timers ──── расписание и состояние последних запусков
Telegram ────────── тревоги и ежедневный health report
Локальные журналы ─ полные root-only данные для расследования

This system reduces the risk of credential guessing, of a login with a stolen password, of unnoticed attacks in web and system logs, of hidden file changes and of missed updates. It also shortens the time between an event and the administrator’s reaction.

It does not replace backups, TLS, secure application development, secret storage, protection of the VPS provider’s control panel and a recovery plan for a compromise that has been tested in advance. If an attacker has already gained full root access, the local tools and reports can be modified as well. For servers with higher requirements the logs are additionally sent to a separate host or to a SIEM, and the AIDE database is kept outside the machine being checked.

Supported systems

The main path is intended for:

  • Debian 12+ and Ubuntu 22.04+;
  • RHEL 9, Rocky Linux 9, AlmaLinux 9, CentOS Stream 9;
  • Fedora, with an allowance for the package names.

The commands are run by an ordinary administrative user through sudo. Before you begin, make sure that the emergency web, VNC or serial console of the VPS provider is available.

Important safety rules

  1. Do not close the current SSH session until key-based login has been verified in a second session.
  2. First open the new SSH port in the firewall, then change sshd.
  3. Create backups before you change PAM, SSH and the firewall.
  4. Do not update the AIDE database automatically after an alert. Investigate every change first.
  5. Do not keep the Telegram token in Git, in the shell history, in process arguments or in world-readable files.
  6. Do not use a permanent NOPASSWD: ALL for an ordinary account. If automation really needs root, set aside a separate user and a separate key.
  7. Without an automatic response component, CrowdSec detects events but does not block them.
  8. An active systemd timer does not prove that the last run succeeded. Check the Result and ExecMainStatus of the service.

Part I. Preparation

1. Identifying the distribution and creating a backup

First we record the initial state: the family and version of the system, the kernel and the presence of the utilities the next steps depend on. Then we save the SSH, PAM and firewall configuration and the list of systemd units into a private directory; this copy is needed for comparison and for a quick return if hardening the access produces an unexpected result.

# Source the trusted file with variables or shared functions.
. /etc/os-release
# Format or print the data in the given safe format.
printf 'OS=%s %s\n' "$ID" "$VERSION_ID"
# Print the version of the running kernel.
uname -r
# Check that every required executable is present.
command -v systemctl sudo curl python3 flock

The next block creates a private directory with a unique timestamp and puts copies of the configuration and snapshots of the rules into it. Run it on the server; the BACKUP variable will stay available in the current root/sudo session for a later rollback. Create the backup directory:

# Set a unique path for the private backup.
BACKUP="/root/server-security-backup-$(date +%Y%m%d-%H%M%S)"
# Create a directory or install a file with the given owner and permissions.
sudo install -d -m 0700 "$BACKUP"

# Save a copy of the file or the tree with its original metadata.
sudo cp -a /etc/ssh "$BACKUP/ssh"
# Save a copy of the file or the tree with its original metadata.
sudo cp -a /etc/pam.d "$BACKUP/pam.d"
# Save a copy of the file or the tree with its original metadata.
sudo cp -a /etc/fail2ban "$BACKUP/fail2ban" 2>/dev/null || true
# Change or verify a permanent firewalld rule.
sudo firewall-cmd --list-all 2>/dev/null \
  | sudo tee "$BACKUP/firewalld-before.txt" >/dev/null || true
# Inspect the actual nftables ruleset.
sudo nft list ruleset 2>/dev/null \
  | sudo tee "$BACKUP/nft-before.txt" >/dev/null || true
# Manage a systemd unit or read its actual state.
sudo systemctl list-unit-files \
  | sudo tee "$BACKUP/unit-files-before.txt" >/dev/null

Do not copy an archive with secrets into public storage. The directory must stay root:root 0700.

2. Installing the base packages

The set is the same in purpose but differs in names and in package manager: APT is used on Debian/Ubuntu, DNF on RHEL-like systems. Besides the protective services we install utilities for bans, lock files, log rotation and automatic updates; after this step all the commands must be available and the current SSH port must be allowed in firewalld.

Debian/Ubuntu

APT first refreshes the index, then installs the full set of dependencies in a single transaction. After the command has run, all the listed utilities must be found by command -v.

# Refresh the local APT package index.
sudo apt update
# Install the listed packages with APT.
sudo apt install -y \
  openssh-server curl ca-certificates python3 util-linux \
  fail2ban aide rkhunter logrotate unattended-upgrades

Separately we install firewalld, which will manage the permanent and the runtime nftables rules. At this step the package is only added; enabling it happens after the SSH port in use has been allowed. Install firewalld:

# Install the listed packages with APT.
sudo apt install -y firewalld

RHEL/Rocky/Alma/CentOS Stream/Fedora

EPEL adds the packages that are missing from the base repositories of RHEL-like systems; once it is connected, DNF installs the same functional set, including the integration of Fail2ban with firewalld.

# Install the listed packages with DNF.
sudo dnf install -y epel-release
# Install the listed packages with DNF.
sudo dnf install -y \
  openssh-server curl ca-certificates python3 util-linux \
  firewalld fail2ban fail2ban-firewalld aide rkhunter \
  logrotate dnf-automatic

The block reads the actual sshd port, checks that the result is not empty and adds it either to a running firewalld or to the configuration of a service that has not been started yet. The lines continued with \ form single commands: comments inside them would break pasting the block as a whole. Do not enable the new firewall until its permanent configuration allows the current SSH port:

# Read the actual port from the effective sshd configuration.
CURRENT_SSH_PORT=$(sudo sshd -T | awk '$1 == "port" {print $2; exit}')
# Check that sshd reported a non-empty numeric port.
test -n "$CURRENT_SSH_PORT"

# Test the condition and choose the branch to run next.
if systemctl is-active --quiet firewalld; then
  # Change or verify a permanent firewalld rule.
  sudo firewall-cmd --permanent \
    --add-port="${CURRENT_SSH_PORT}/tcp"
  # Change or verify a permanent firewalld rule.
  sudo firewall-cmd --reload
# If the condition does not hold, fall back to the safe alternative.
else
  # Add the allow rule to the permanent configuration of a firewalld that is not running yet.
  sudo firewall-offline-cmd --zone=public \
    --add-port="${CURRENT_SSH_PORT}/tcp"
  # Manage a systemd unit or read its actual state.
  sudo systemctl enable --now firewalld
fi

# Change or verify a permanent firewalld rule.
sudo firewall-cmd --query-port="${CURRENT_SSH_PORT}/tcp"

These commands are run on the server and confirm a working SSH, an active firewalld and the presence of all the console utilities. For SSH both common systemd service names are taken into account: sshd and ssh. Check the packages and the services instead of relying on the absence of errors in the installer:

# Check the actual state of a systemd unit.
systemctl is-active sshd 2>/dev/null || systemctl is-active ssh
# Check the actual state of a systemd unit.
systemctl is-active firewalld
# Check that every required executable is present.
command -v fail2ban-client aide rkhunter curl python3 flock

Part II. SSH and the firewall without the risk of losing access

3. Creating an administrative user and a key

We separate day-to-day administration from the root account and replace the password with an Ed25519 cryptographic key. The private part of the key stays on the client machine and the server receives only the public key; before forbidding passwords we make sure that the admin user logs in successfully in a second session.

On the client we create a new private and public key; the result will be id_ed25519 and id_ed25519.pub, or files with the name chosen at the prompt. The private key is not copied to the server. On the client machine, not on the server:

# Generate an Ed25519 key pair on the client machine.
ssh-keygen -t ed25519 -a 64 -C "admin@my-server"

Never give the private file ~/.ssh/id_ed25519 to anyone. Only the .pub file is installed on the server.

On the server this block creates admin if needed, adds it to the system administrative group and prepares authorized_keys with restrictive permissions. The lines of the case-like branches already carry the original distribution notes; they have been kept unchanged. On the server:

# Check whether the user exists and create it only if it does not.
id -u admin >/dev/null 2>&1 \
  || sudo useradd --create-home --shell /bin/bash admin

# Test the condition and choose the branch to run next.
if getent group sudo >/dev/null; then
  # Add `admin` to the Debian/Ubuntu administrators group.
  sudo usermod -aG sudo admin      # Debian/Ubuntu
# If the previous condition is false, test the alternative marker.
elif getent group wheel >/dev/null; then
  # Add `admin` to the administrative group of a RHEL-like system.
  sudo usermod -aG wheel admin     # RHEL-подобные
# If the condition does not hold, fall back to the safe alternative.
else
  # Report that no suitable administrative group was found.
  echo 'Не найдена административная группа sudo/wheel' >&2
  # Exit the script with the chosen result code.
  exit 1
fi

# Create a directory or install a file with the given owner and permissions.
sudo install -d -o admin -g admin -m 0700 /home/admin/.ssh
# Create `authorized_keys` only if it is missing, without wiping the existing keys.
sudo test -e /home/admin/.ssh/authorized_keys \
  || sudo install -o admin -g admin -m 0600 /dev/null \
       /home/admin/.ssh/authorized_keys
# Enforce the expected owner and group.
sudo chown admin:admin /home/admin/.ssh/authorized_keys
# Set the minimum required access permissions.
sudo chmod 0600 /home/admin/.ssh/authorized_keys

The command appends only the public key, removes exact duplicates and restores the owner, the mode and the SELinux context. The continued command pipeline is explained as a whole before its first line, so that the block can be copied and pasted safely. Add the public key without overwriting the existing ones:

# Format or print the data in the given safe format.
printf '%s\n' 'ssh-ed25519 ЗАМЕНИТЕ_НА_СВОЙ_ПУБЛИЧНЫЙ_КЛЮЧ' \
  | sudo tee -a /home/admin/.ssh/authorized_keys >/dev/null
# Remove duplicate keys without changing the destination file.
sudo sort -u -o /home/admin/.ssh/authorized_keys /home/admin/.ssh/authorized_keys
# Enforce the expected owner and group.
sudo chown admin:admin /home/admin/.ssh/authorized_keys
# Set the minimum required access permissions.
sudo chmod 0600 /home/admin/.ssh/authorized_keys
# Restore the standard SELinux contexts if SELinux is available.
sudo restorecon -RF /home/admin/.ssh 2>/dev/null || true

The test is run on the client with BatchMode=yes, so a password cannot hide a failure of key authentication. A successful command must print information about admin; the check of passwordless sudo here is diagnostic and is not required in order to continue. In a second local tab, verify the login while forbidding the fallback to password authentication:

# Open a separate test SSH session using only the specified key.
ssh -o BatchMode=yes -o IdentitiesOnly=yes \
  -i ~/.ssh/id_ed25519 admin@SERVER_IP 'id; sudo -n true || true'

If the key does not work, do not disable the password.

4. Choosing the SSH port and configuring the firewall in advance

We change the SSH port only after the matching allow rule has been added to the permanent configuration of the firewall, otherwise the next reload of the configuration may cut off remote access. The SSH_PORT variable makes it possible to use a single numeric value in the commands, and the final check must show the new port among the allowed ones.

A non-standard port reduces the noise in the logs, but it is not a protection of its own. The examples use a variable:

# Store the chosen new SSH port.
SSH_PORT=2207

On the server we add the port to the permanent zone, reload the rules and immediately check the result through firewalld. The answer of the last command must be yes before sshd is edited. Open the new port before changing SSH:

# Change or verify a permanent firewalld rule.
sudo firewall-cmd --permanent --add-port="${SSH_PORT}/tcp"
# Change or verify a permanent firewalld rule.
sudo firewall-cmd --reload
# Change or verify a permanent firewalld rule.
sudo firewall-cmd --query-port="${SSH_PORT}/tcp"

The example allows HTTP/HTTPS, removes the standard ssh service and prints the resulting zone. Apply the removal only after a successful login through the new numeric port; the set of web services must match the real role of the server. If the server provides only SSH and web services, the resulting firewall configuration may look like this:

# Change or verify a permanent firewalld rule.
sudo firewall-cmd --permanent --add-service=http
# Change or verify a permanent firewalld rule.
sudo firewall-cmd --permanent --add-service=https
# Change or verify a permanent firewalld rule.
sudo firewall-cmd --permanent --remove-service=ssh
# Change or verify a permanent firewalld rule.
sudo firewall-cmd --reload
# Change or verify a permanent firewalld rule.
sudo firewall-cmd --list-all

Remove the standard ssh service only after you have verified the new port.

5. Hardening sshd

sshd is the server side of OpenSSH: the process that accepts incoming SSH connections and decides who is allowed to log in and in which way. In this section we will reduce the attack surface: we will move SSH to the chosen port, forbid direct root login, disable passwords and leave key-based authentication. In addition, we will limit the number of login attempts and disable the unused X11 forwarding.

The main file /etc/ssh/sshd_config can be edited directly, but that makes it harder to tell your own settings from the parameters installed by the package. Instead we use a drop-in — a separate small configuration file in the /etc/ssh/sshd_config.d/ directory. OpenSSH reads such files together with the main config, so the hardening settings can be checked, replaced or removed independently of the rest of the configuration.

The name 60-hardening.conf states the purpose of the file clearly and defines its place in the order in which the configuration is read. First we create the drop-in directory if it does not exist yet, then we open the new file in the safe sudoedit editor:

# Create a directory or install a file with the given owner and permissions.
sudo install -d -m 0755 /etc/ssh/sshd_config.d
# Open the new drop-in in an editor with safe privilege elevation.
sudoedit /etc/ssh/sshd_config.d/60-hardening.conf

In the opened drop-in save the parameters below without any shell syntax: these are directives of sshd itself. After the configuration is reloaded the daemon will listen on port 2207, accept keys, forbid root login and passwords and close inactive connections according to the configured policy. Contents:

Port 2207
PermitRootLogin no
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
MaxAuthTries 3
X11Forwarding no
ClientAliveInterval 300
ClientAliveCountMax 2
UsePAM yes

First sshd parses the whole configuration without starting, then it prints the values that are really applied, taking the main file and the drop-in into account. The continued pipeline filters this output as a single command. Check the syntax and the effective values:

# Check the syntax or the effective sshd configuration.
sudo sshd -t
# Check the syntax or the effective sshd configuration.
sudo sshd -T | grep -E \
  '^(port|permitrootlogin|pubkeyauthentication|passwordauthentication|kbdinteractiveauthentication|maxauthtries|x11forwarding|clientaliveinterval|clientalivecountmax) '

Reloading applies the verified configuration without interrupting the current session; the fallback name ssh covers Debian/Ubuntu. After that ss must show a listening socket on ${SSH_PORT}. Do not run systemctl restart sshd blindly. Reload the configuration first:

# Manage a systemd unit or read its actual state.
sudo systemctl reload sshd 2>/dev/null || sudo systemctl reload ssh
# Check that sshd really listens on the expected TCP port.
sudo ss -ltnp | grep ":${SSH_PORT} "

Run the block on the client machine: it forbids the fallback to password authentication and explicitly selects the key and the new port. The expected output is key login OK; only after it is it safe to close the old session. Check the new session:

# Open a separate test SSH session using only the specified key.
ssh -o BatchMode=yes -o IdentitiesOnly=yes \
  -i ~/.ssh/id_ed25519 -p 2207 admin@SERVER_IP 'printf "key login OK\n"'

Only after a success can you close the old session and remove the rule for the old SSH port.


Part III. Fail2ban and CrowdSec

6. Configuring Fail2ban for the actual SSH port

Fail2ban groups its response rules into a jail — a set consisting of a log filter, an attempt threshold, a ban duration and a firewall action for one particular service. Here the main jail protects the actual sshd port, while recidive blocks for a long time the addresses that get banned repeatedly; after the start both mechanisms must load without errors.

Do not use port = ssh if sshd listens on a non-standard port: the service name is usually resolved to 22/tcp.

Save this Fail2ban drop-in on the server: the [DEFAULT] section defines the common policy, [sshd] a fast ban on port 2207, and [recidive] a long-lasting response to repeat offenders. The result will be a configuration that can be checked before the daemon is restarted. Create /etc/fail2ban/jail.d/sshd.local:

[DEFAULT]
backend = systemd
bantime = 1h
findtime = 10m
maxretry = 5
bantime.increment = true
bantime.factor = 2
bantime.maxtime = 1w
ignoreip = 127.0.0.1/8 ::1
banaction = firewallcmd-rich-rules
banaction_allports = firewallcmd-rich-rules

[sshd]
enabled = true
port = 2207
maxretry = 3

[recidive]
enabled = true
backend = systemd
bantime = 1w
findtime = 1d
maxretry = 5
banaction = firewallcmd-rich-rules
banaction_allports = firewallcmd-rich-rules

On RHEL with fail2ban-firewalld the file /etc/fail2ban/jail.d/00-firewalld.conf is usually created already. If it is not, add to [DEFAULT]:

banaction = firewallcmd-rich-rules
banaction_allports = firewallcmd-rich-rules

First Fail2ban fully parses the configuration, then the service is enabled and restarted in order to apply the jail. The first step must finish without errors; otherwise do not restart the service. Before the restart:

# Check the configuration, the readiness or the status of Fail2ban.
sudo fail2ban-client -t
# Manage a systemd unit or read its actual state.
sudo systemctl enable --now fail2ban
# Manage a systemd unit or read its actual state.
sudo systemctl restart fail2ban

The loop polls Fail2ban itself for up to 20 seconds instead of taking an active systemd service as proof of readiness. After the loop the mandatory ping and the statuses must show a responding daemon and a loaded sshd jail. Wait until the application is ready:

# Repeat the check a limited number of times to wait for the service to become ready.
for i in $(seq 1 20); do
  # Check the configuration, the readiness or the status of Fail2ban.
  sudo fail2ban-client ping >/dev/null 2>&1 && break
  # Pause briefly before the next readiness check.
  sleep 1
done
# Check the configuration, the readiness or the status of Fail2ban.
sudo fail2ban-client ping
# Check the configuration, the readiness or the status of Fail2ban.
sudo fail2ban-client status
# Check the configuration, the readiness or the status of Fail2ban.
sudo fail2ban-client status sshd

Read the loaded jail values through the client and additionally find port 2207 in the debug view of the configuration. The output must match the configured thresholds and the actual sshd port. Check the effective values:

# Check the configuration, the readiness or the status of Fail2ban.
sudo fail2ban-client get sshd maxretry
# Check the configuration, the readiness or the status of Fail2ban.
sudo fail2ban-client get sshd findtime
# Check the configuration, the readiness or the status of Fail2ban.
sudo fail2ban-client get sshd bantime
# Check the configuration, the readiness or the status of Fail2ban.
sudo fail2ban-client -d | grep -A3 -B3 "'port', '2207'"

Do not test a ban from the single address you use to administer the server. Use a separate connection and a provider console prepared in advance.

This command lifts the test ban of one documentation address in the sshd jail. Replace TEST_IP only with the address you deliberately used in a safe test. Unban:

# Check the configuration, the readiness or the status of Fail2ban.
sudo fail2ban-client set sshd unbanip TEST_IP

7. Installing CrowdSec and the firewall bouncer

The CrowdSec detection mechanism (Security Engine) recognises attack scenarios and creates decisions, but it does not filter traffic itself. A bouncer is a separate enforcer that receives these decisions through the local API and transfers them into nftables; at the end of the section we check not only both services but also the appearance of a test decision in the firewall.

CrowdSec consists of at least two parts:

  • the detection mechanism (Security Engine) analyses the logs and creates decisions;
  • the automatic response component applies the decisions to the traffic.

The current official instructions: https://docs.crowdsec.net/u/getting_started/installation/linux/.

Instead of an unconditional curl | sh, download the installation script separately and inspect it:

# Download the installation script to a temporary file without running it.
curl -fsSL https://install.crowdsec.net -o /tmp/install-crowdsec.sh
# Review the downloaded script before executing it.
less /tmp/install-crowdsec.sh
# Run the previously reviewed bootstrap with elevated privileges.
sudo sh /tmp/install-crowdsec.sh
# Delete the temporary bootstrap after the installation.
rm -f /tmp/install-crowdsec.sh

Debian/Ubuntu

First we refresh the index and check which connected repository CrowdSec will be taken from. The installation must add the detection mechanism and the firewall bouncer as package-managed components.

# Refresh the local APT package index.
sudo apt update
# Check the available version and the repository of the package.
apt-cache policy crowdsec
# Install the listed packages with APT.
sudo apt install -y crowdsec crowdsec-firewall-bouncer-iptables

RHEL/CentOS/Fedora

DNF shows the metadata of the candidate before the installation, so that the repository and the version can be checked. Then a single command installs the detection mechanism and the bouncer.

# Show the source and the metadata of the package before installing it.
sudo dnf info crowdsec
# Install the listed packages with DNF.
sudo dnf install -y crowdsec crowdsec-firewall-bouncer-iptables

The official documentation uses the package name crowdsec-firewall-bouncer-iptables; the bouncer itself can work in nftables mode. Prefer the packaged installation. If the binary is installed by hand and rpm -qf/dpkg -S finds no owner, updates and provenance control become your responsibility.

The block enables the detection mechanism and the bouncer at boot, checks that they are active and reads the result of the last run. For both services the expected state is active, with Result=success and ExecMainStatus=0. Enable and check:

# Manage a systemd unit or read its actual state.
sudo systemctl enable --now crowdsec
# Manage a systemd unit or read its actual state.
sudo systemctl enable --now crowdsec-firewall-bouncer
# Manage a systemd unit or read its actual state.
sudo systemctl is-active crowdsec crowdsec-firewall-bouncer
# Manage a systemd unit or read its actual state.
sudo systemctl show crowdsec crowdsec-firewall-bouncer \
  -p Result -p ExecMainStatus

Collections are ready-made sets of CrowdSec parsers and scenarios; install only those for which the server actually writes logs. After the set has been changed, the detection mechanism is restarted so that it loads the configuration. Install the collections for the services that really run:

# Manage CrowdSec objects or read their actual state.
sudo cscli collections install crowdsecurity/linux crowdsecurity/sshd
# If Nginx is present:
sudo cscli collections install crowdsecurity/nginx \
  crowdsecurity/base-http-scenarios crowdsecurity/http-cve
# Manage a systemd unit or read its actual state.
sudo systemctl restart crowdsec

The commands show the installed collections, the processed lines, the decisions, the registered bouncer and the recent logs of the two services. This makes it possible to tell an empty installation from a system that reads events and applies decisions. Check the log sources and the metrics:

# Manage CrowdSec objects or read their actual state.
sudo cscli collections list
# Manage CrowdSec objects or read their actual state.
sudo cscli metrics
# Manage CrowdSec objects or read their actual state.
sudo cscli decisions list
# Manage CrowdSec objects or read their actual state.
sudo cscli bouncers list
# Read the systemd journal events that belong to the service.
sudo journalctl -u crowdsec -u crowdsec-firewall-bouncer \
  --since '30 minutes ago' --no-pager

Keep an important difference in mind: cscli decisions list -o json may return a list of alerts (alerts) that contain decisions (decisions) inside them. For automation do not count the rows of the table; parse the JSON according to the version you actually have.

Run a safe test of a local decision for a documentation address that does not belong to you:

# Manage CrowdSec objects or read their actual state.
sudo cscli decisions add --ip 192.0.2.123 --duration 2m --reason test
# Manage CrowdSec objects or read their actual state.
sudo cscli decisions list
# Inspect the actual nftables ruleset.
sudo nft list ruleset | grep -i crowdsec
# Manage CrowdSec objects or read their actual state.
sudo cscli decisions delete --ip 192.0.2.123

Never publish the bouncer API key from /etc/crowdsec/bouncers/*.yaml.


Part IV. The shared Telegram transport

8. Creating the Telegram Bot API configuration

We move the Telegram secrets into a separate environment file available only to root, so that the scripts can read them without embedding the token in the code or in process arguments. After saving it we check the owner, the 0600 mode and the variable names without printing the values on the screen.

Another article by the author in this same blog — Telegram Bot API Basics — covers BotFather, the token, the chat identifier and the basic API methods.

Create a bot through the official @BotFather, start a dialogue with it and obtain the chat identifier. Do not paste the token directly into a shell command: it will end up in the history.

# Create a directory or install a file with the given owner and permissions.
sudo install -d -o root -g root -m 0700 /etc/server-security-monitor
# Create the secrets file only if it is missing, without overwriting the existing configuration.
sudo test -e /etc/server-security-monitor/telegram.env \
  || sudo install -o root -g root -m 0600 /dev/null \
       /etc/server-security-monitor/telegram.env
# Enforce the expected owner and group.
sudo chown root:root /etc/server-security-monitor/telegram.env
# Set the minimum required access permissions.
sudo chmod 0600 /etc/server-security-monitor/telegram.env
# Open the private secrets file with `sudoedit`.
sudoedit /etc/server-security-monitor/telegram.env

In telegram.env enter only two NAME=value pairs, replacing the placeholders with your own secrets; the shell ignores the comment lines that have been added. The file stays on the server and must not end up in Git or in terminal output. Contents:

# Set a placeholder bot token that has to be replaced locally.
TELEGRAM_BOT_TOKEN="ЗАМЕНИТЕ_НА_ТОКЕН"
# Set a placeholder chat identifier that has to be replaced locally.
TELEGRAM_CHAT_ID="ЗАМЕНИТЕ_НА_CHAT_ID"

The commands print the metadata of the file and only the names of the variables with a <set> marker. The values of the token and of the chat identifier do not reach the terminal during this check. Check only the structure and the permissions, without printing the values:

# Print only the permissions, the owner and the name of the protected file.
sudo stat -c '%A %U:%G %n' /etc/server-security-monitor/telegram.env
# Show the names of the variables that are set, without revealing their values.
sudo awk -F= '/^[A-Z_]+=/ {print $1"=<set>"}' \
  /etc/server-security-monitor/telegram.env

9. The shared library

We put the repetitive work with Telegram into common.sh: the library loads the private config, escapes HTML, limits the size of the message and checks the Bot API response. All the following scripts will include the same verified transport, so a delivery failure will become an error code and the secrets will not end up in the curl command line.

The block creates a library directory owned by root with mode 0750 and opens common.sh through sudoedit. The result must be a file that an ordinary user cannot modify. Create the directory:

# Create a directory or install a file with the given owner and permissions.
sudo install -d -o root -g root -m 0750 \
  /usr/local/libexec/server-security-monitor
# Open the shared library file with `sudoedit`.
sudoedit /usr/local/libexec/server-security-monitor/common.sh

Save the following Bash code in common.sh; it is a library and sends nothing on its own. The embedded Python documents (heredoc) encode the form and check the JSON response: the lines between <<'PY' and PY must not be commented as Bash, because that would change the program passed to Python. Contents of common.sh:

#!/usr/bin/env bash
# Shared functions. The file must be owned by root and must not be modified by ordinary users.

# Set the path to the private Telegram config, allowing a test override.
SSM_CONF=${SSM_CONF:-/etc/server-security-monitor/telegram.env}
# Set the absolute path to curl, allowing a test override.
SSM_CURL=${SSM_CURL:-/usr/bin/curl}

# Declare the function that safely loads the Telegram configuration.
load_telegram_config() {
  # Test the condition and, if needed, end the step early with a safe result.
  [[ -r "$SSM_CONF" ]] || {
    # Format or print the data in the given safe format.
    printf 'Telegram config is not readable: %s\n' "$SSM_CONF" >&2
    # Return an error code to the calling function.
    return 2
  }

  # This is a root-only trusted configuration file.
  # shellcheck disable=SC1090
  # Export the variables that will be loaded from the trusted file.
  set -a
  # Source the trusted file with variables or shared functions.
  . "$SSM_CONF"
  # Turn automatic export off once the configuration has been loaded.
  set +a

  # Require a non-empty value for the mandatory configuration variable.
  : "${TELEGRAM_BOT_TOKEN:?TELEGRAM_BOT_TOKEN is missing}"
  # Require a non-empty value for the mandatory configuration variable.
  : "${TELEGRAM_CHAT_ID:?TELEGRAM_CHAT_ID is missing}"
}

# Declare the filter that escapes special HTML characters.
html_escape() {
  # Escape the characters that have a special meaning in HTML.
  sed -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g'
}

# Declare the shared function that sends a message through the Telegram Bot API.
tg_send() {
  # Build the notification text from the already escaped fields.
  local msg=${1:-}
  # Test the condition and, if needed, end the step early with a safe result.
  [[ -n "$msg" ]] || return 0
  # Load the token and the chat ID before building the request.
  load_telegram_config

  # Telegram accepts up to 4096 characters; leave some headroom.
  if ((${#msg} > 3900)); then
    # Build the notification text from the already escaped fields.
    msg="${msg:0:3820}
…сообщение сокращено; полный отчёт сохранён на сервере"
  fi

  # Declare function-local variables so that the global environment is not changed.
  local response body
  # Encode the request parameters without exposing them in the argv of the process.
  body=$(CHAT_ID="$TELEGRAM_CHAT_ID" MESSAGE="$msg" python3 - <<'PY'
import os, urllib.parse
print(urllib.parse.urlencode({
    "chat_id": os.environ["CHAT_ID"],
    "text": os.environ["MESSAGE"],
    "parse_mode": "HTML",
    "disable_web_page_preview": "true",
}))
PY
  )

  # The URL with the token is passed through a separate FD and the POST body through stdin:
  # the token, the chat ID and the text never appear in the argv of the curl process.
  exec 3<<<"url = \"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage\""
  # Send the form with timeouts and store the JSON response of the Bot API.
  response=$(printf '%s' "$body" | "$SSM_CURL" \
      --config /dev/fd/3 \
      --fail --silent --show-error \
      --connect-timeout 3 --max-time 12 \
      -X POST \
      -H 'Content-Type: application/x-www-form-urlencoded' \
      --data-binary @-) || {
    # Close the descriptor holding the URL after a failed request.
    exec 3<&-
    # Return the delivery error to the calling script.
    return 1
  }
  # Close the descriptor holding the URL after a successful request.
  exec 3<&-

  # Parse the JSON response and require the boolean field `ok=true`.
  RESPONSE=$response python3 - <<'PY'
import json, os, sys
try:
    ok = json.loads(os.environ["RESPONSE"]).get("ok") is True
except Exception:
    ok = False
sys.exit(0 if ok else 1)
PY
}

Assign the library to root, allow execution only for the owner and the group and run bash -n without executing the code. A zero status of the last command confirms that the shell syntax is correct. Set the permissions and check the syntax:

# Enforce the expected owner and group.
sudo chown root:root /usr/local/libexec/server-security-monitor/common.sh
# Set the minimum required access permissions.
sudo chmod 0750 /usr/local/libexec/server-security-monitor/common.sh
# Check the syntax of the shell script without executing it.
sudo bash -n /usr/local/libexec/server-security-monitor/common.sh

Another article by the author in this same blog — Working with Telegram Bot API via Postman — shows how to check sendMessage by hand and see the JSON response of the API.

This short executable file includes the library and sends a message with the host name. It is needed in order to check the whole path: reading the private configuration, the HTTPS request and the verification of the API response. Create the test sender /usr/local/libexec/server-security-monitor/send-test.sh:

#!/usr/bin/env bash
# Enable strict handling of errors and of unset variables.
set -Eeuo pipefail
# Restrict the search for executables to trusted system directories.
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# Source the trusted file with variables or shared functions.
. /usr/local/libexec/server-security-monitor/common.sh
# Send the composed message through the shared hardened function.
tg_send "✅ <b>Тест мониторинга</b> на <code>$(hostname -f 2>/dev/null || hostname)</code>"

Give the test sender execute permissions for root and its group only and run it on the server. A successful result is the test message received and a zero exit code of the command.

# Set the minimum required access permissions.
sudo chmod 0750 /usr/local/libexec/server-security-monitor/send-test.sh
# Run the test sender as root so that it can read the secrets.
sudo /usr/local/libexec/server-security-monitor/send-test.sh

Do not move on until the test message has arrived.


Part V. AIDE and rkhunter

10. Initialising AIDE

The first initialisation creates a baseline — a trusted base record of checksums, owners, permissions and other metadata. The paths of the working and of the new database differ between distributions; the expected result is an installed database accessible only to root and a clean aide --check command with code 0.

AIDE stores the checksums and the metadata of files. Its database is itself a critical asset.

RHEL-like systems

Here aide --init creates the new database and install copies it to the working path with restrictive permissions. Checking that the intermediate file exists prevents an empty or failed result from being accepted.

# Check the configuration, create the baseline or compare the current state with AIDE.
sudo aide --config-check
# Check the configuration, create the baseline or compare the current state with AIDE.
sudo aide --init
# Make sure that AIDE really created the new database.
sudo test -f /var/lib/aide/aide.db.new.gz
# Create a directory or install a file with the given owner and permissions.
sudo install -o root -g root -m 0600 \
  /var/lib/aide/aide.db.new.gz /var/lib/aide/aide.db.gz

Debian/Ubuntu

aideinit creates the database taking the Debian configuration into account, and update-aide.conf assembles the final config from the fragments. After that the syntax check must finish without any error messages.

On Debian the paths may differ, and the configuration may be assembled from /etc/aide/aide.conf.d:

# Check the configuration, create the baseline or compare the current state with AIDE.
sudo aideinit
# Assemble the final AIDE configuration from the Debian fragments.
sudo update-aide.conf
# Check the configuration, create the baseline or compare the current state with AIDE.
sudo aide --config-check

The command reads only the database path directives from all the possible AIDE configs. The continued line is a single grep; from its output you have to determine the working and the new database of your own system. Determine the actual paths from the configuration:

# Extract only the required lines of the configuration or of the log.
sudo grep -RhsE '^(database|database_in|database_out)=' \
  /etc/aide.conf /etc/aide/aide.conf* /etc/aide/aide.conf.d 2>/dev/null

Run a full comparison against the trusted database (baseline) you have just created and immediately print the exit code. On an initially clean system AIDE rc=0 is expected. The final check must return code 0:

# Check the configuration, create the baseline or compare the current state with AIDE.
sudo aide --check
# Format or print the data in the given safe format.
printf 'AIDE rc=%s\n' "$?"

The codes 1, 2 and 4 are a bit mask of added, removed and changed objects. Their combinations up to 7 mean that changes were found, not necessarily that the scanner is broken.

11. The AIDE script

The wrapper runs the integrity check under a lock, stores the full report with 0600 permissions and sends only a short, escaped summary. The ordinary AIDE codes about found changes are treated as a result of the check, while a real scanner error is passed to systemd as a non-zero code.

First we create a private report directory and open the new script file through sudoedit. After this block a 0700 directory and an aide-check.sh ready to be filled in must exist.

# Create a directory or install a file with the given owner and permissions.
sudo install -d -o root -g root -m 0700 \
  /var/log/server-security-monitor/aide
# Open the AIDE wrapper file with `sudoedit`.
sudoedit /usr/local/libexec/server-security-monitor/aide-check.sh

Save the following standalone script as aide-check.sh. The multi-line grep and awk expressions and the Telegram text are commented before they begin: inserting comments inside quotes or continued lines would change the data and would break safe copying and pasting.

#!/usr/bin/env bash
# Enable strict handling of errors and of unset variables.
set -Eeuo pipefail
# Restrict the search for executables to trusted system directories.
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# Pin the locale so that output parsing does not depend on the system language.
export LC_ALL=C
# Source the trusted file with variables or shared functions.
. /usr/local/libexec/server-security-monitor/common.sh

# Locate AIDE or accept an explicitly specified path.
AIDE_BIN=${AIDE_BIN:-$(command -v aide)}
# Set the private directory for the full reports.
LOG_ROOT=/var/log/server-security-monitor/aide
# Set the lock file that rules out parallel runs.
LOCK_FILE=/run/lock/server-security-aide.lock
# Limit the number of paths in the short notification.
MAX_PATHS=30

# Create the protected directory with explicitly specified permissions.
install -d -o root -g root -m 0700 "$LOG_ROOT"
# Open a separate file descriptor for the lock file or for passing parameters privately.
exec 9>"$LOCK_FILE"
# Take a non-blocking lock and do not start a second instance.
flock -n 9 || exit 0

# Build a timestamp for the unique report name.
stamp=$(date '+%Y%m%d-%H%M%S')
# Assemble the full path of the new report.
report="$LOG_ROOT/aide-$stamp.log"

# Temporarily allow a non-zero scanner code so that the result can be saved and classified.
set +e
# Run AIDE and save the full output into the private report.
"$AIDE_BIN" --check >"$report" 2>&1
# Save the exit code of the scanner before strict mode is switched on again.
rc=$?
# Enable strict handling of errors and of unset variables.
set -e
# Restrict the permissions of the report that was created.
chmod 0600 "$report"

# Test the condition and, if needed, end the step early with a safe result.
[[ $rc -eq 0 ]] && exit 0

# Extract the numeric summary from the full report.
summary=$(grep -iE \
  'Total number of entries:[[:space:]]*[0-9]+|Added entries:[[:space:]]*[0-9]+|Removed entries:[[:space:]]*[0-9]+|Changed entries:[[:space:]]*[0-9]+' \
  "$report" | head -n 4 || true)

# Extract a limited list of the changed paths.
paths=$(awk -v max="$MAX_PATHS" '
  /^[[:space:]]*(Added|Removed|Changed) entries:[[:space:]]*$/ {
    section=$0
    sub(/^[[:space:]]*/, "", section)
    next
  }
  section != "" && /:[[:space:]]+\// {
    if (!seen[section]++) print section
    print
    if (++count >= max) exit
  }
' "$report")

# Test the condition and, if needed, end the step early with a safe result.
[[ -n "$summary" ]] || summary="AIDE exit code: $rc"
# Test the condition and, if needed, end the step early with a safe result.
[[ -n "$paths" ]] || paths="Пути не извлечены; см. полный локальный отчёт."

# Escape the summary before inserting it into the HTML message.
summary_html=$(printf '%s' "$summary" | html_escape)
# Escape the paths before inserting them into the HTML message.
paths_html=$(printf '%s' "$paths" | html_escape)
# Get the fully qualified host name with a fallback.
host=$(hostname -f 2>/dev/null || hostname)

# Test the condition and choose the branch to run next.
if (( rc <= 7 )); then
  # Choose the notification title according to the result of the check.
  title='🧬 <b>AIDE: изменения файлов</b>'
# If the condition does not hold, fall back to the safe alternative.
else
  # Choose the notification title according to the result of the check.
  title="🚨 <b>AIDE: ошибка проверки, код ${rc}</b>"
fi

# Send the composed message through the shared hardened function.
tg_send "${title} на <code>${host}</code>
$(date '+%F %T %Z')
<pre>${summary_html}</pre>
<pre>${paths_html}</pre>
Полный отчёт: <code>${report}</code>"

# File changes are a successfully delivered finding; a scanner error is a unit failure.
(( rc <= 7 )) && exit 0
# Exit the script with the chosen result code.
exit "$rc"

12. The rkhunter script

For rkhunter we create a separate wrapper with its own log and flock, so that the heuristic check does not overlap with another run. Warnings will reach Telegram and the full local report, while execution errors will be visible to systemd through a non-zero status.

First we create a separate report directory available only to root and open rkhunter-check.sh. Separating the directories makes rotation and the investigation of each scanner’s results easier.

# Create a directory or install a file with the given owner and permissions.
sudo install -d -o root -g root -m 0700 \
  /var/log/server-security-monitor/rkhunter
# Open the rkhunter wrapper file with `sudoedit`.
sudoedit /usr/local/libexec/server-security-monitor/rkhunter-check.sh

Save the following standalone script as rkhunter-check.sh. The multi-line message and the continued scanner command are explained before the logical step begins, not inside the text that is being built.

#!/usr/bin/env bash
# Enable strict handling of errors and of unset variables.
set -Eeuo pipefail
# Restrict the search for executables to trusted system directories.
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# Pin the locale so that output parsing does not depend on the system language.
export LC_ALL=C
# Source the trusted file with variables or shared functions.
. /usr/local/libexec/server-security-monitor/common.sh

# Locate rkhunter or accept an explicitly specified path.
RKHUNTER_BIN=${RKHUNTER_BIN:-$(command -v rkhunter)}
# Set the private directory for the full reports.
LOG_ROOT=/var/log/server-security-monitor/rkhunter
# Set the lock file that rules out parallel runs.
LOCK_FILE=/run/lock/server-security-rkhunter.lock

# Create the protected directory with explicitly specified permissions.
install -d -o root -g root -m 0700 "$LOG_ROOT"
# Open a separate file descriptor for the lock file or for passing parameters privately.
exec 9>"$LOCK_FILE"
# Take a non-blocking lock and do not start a second instance.
flock -n 9 || exit 0

# Build a timestamp for the unique report name.
stamp=$(date '+%Y%m%d-%H%M%S')
# Assemble the full path of the new report.
report="$LOG_ROOT/rkhunter-$stamp.log"

# Temporarily allow a non-zero scanner code so that the result can be saved and classified.
set +e
# Run rkhunter and save the full output into the private report.
"$RKHUNTER_BIN" --cronjob --report-warnings-only --nocolors \
  >"$report" 2>&1
# Save the exit code of the scanner before strict mode is switched on again.
rc=$?
# Enable strict handling of errors and of unset variables.
set -e
# Restrict the permissions of the report that was created.
chmod 0600 "$report"

# Test the condition and choose the branch to run next.
if [[ -s "$report" ]]; then
  # Take a short escaped excerpt of the report.
  excerpt=$(head -n 35 "$report" | html_escape)
  # Get the fully qualified host name with a fallback.
  host=$(hostname -f 2>/dev/null || hostname)
  # Send the composed message through the shared hardened function.
  tg_send "🦠 <b>rkhunter: предупреждения</b> на <code>${host}</code>
$(date '+%F %T %Z')
<pre>${excerpt}</pre>
Полный отчёт: <code>${report}</code>"
fi

# On common builds rc=1 accompanies warnings; >=2 is a run failure.
if (( rc >= 2 )); then
  # Test the condition and, if needed, end the step early with a safe result.
  [[ -s "$report" ]] || tg_send "🚨 <b>rkhunter: ошибка запуска</b>, код ${rc}"
  # Exit the script with the chosen result code.
  exit "$rc"
fi
# Exit the script with the chosen result code.
exit 0

Never run rkhunter --propupd automatically. This command declares the current state trusted. First confirm the origin of the changes through the package manager and the update log.

13. systemd services and timers

Each scanner gets a service of the oneshot type, which performs a single check and exits, and a timer, which starts that service on a schedule. The random delay spreads the load, Persistent=true catches up a missed run after the server has been switched off, and the time limits prevent a hung scanner from running forever.

Save the service file below as /etc/systemd/system/aide-check.service. It runs a single AIDE check with a low priority, a restrictive permission mask and a limit of 20 minutes; after it finishes the normal state will be inactive and the result success. /etc/systemd/system/aide-check.service:

[Unit]
Description=AIDE file integrity check
After=local-fs.target network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/libexec/server-security-monitor/aide-check.sh
Nice=10
IOSchedulingClass=idle
UMask=0077
TimeoutStartSec=20min

Save the timer file next to the service file. It schedules a daily run at about 04:00, adds up to 15 minutes of random delay and performs a missed task after the next boot. /etc/systemd/system/aide-check.timer:

[Unit]
Description=Daily AIDE integrity check

[Timer]
OnCalendar=*-*-* 04:00:00
RandomizedDelaySec=900
Persistent=true

[Install]
WantedBy=timers.target

This service of the oneshot type runs the rkhunter wrapper with the same limits, but gives the scanner up to 30 minutes. Save it at the specified path as root:root. /etc/systemd/system/rkhunter-check.service:

[Unit]
Description=rkhunter rootkit scan
After=local-fs.target network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/libexec/server-security-monitor/rkhunter-check.sh
Nice=10
IOSchedulingClass=idle
UMask=0077
TimeoutStartSec=30min

The timer starts rkhunter at about 04:30 with a random delay and catches up a missed schedule. Spreading the times apart reduces the chance of two scanners loading the machine at once. /etc/systemd/system/rkhunter-check.timer:

[Unit]
Description=Daily rkhunter scan

[Timer]
OnCalendar=*-*-* 04:30:00
RandomizedDelaySec=900
Persistent=true

[Install]
WantedBy=timers.target

The commands enforce safe owners and modes, check Bash and the systemd services, reload the manager configuration and enable both timers. The continued file lists stay single commands and are commented only before their first line. Set the permissions and activate:

# Enforce the expected owner and group.
sudo chown root:root \
  /usr/local/libexec/server-security-monitor/*.sh \
  /etc/systemd/system/{aide-check,rkhunter-check}.{service,timer}
# Set the minimum required access permissions.
sudo chmod 0750 /usr/local/libexec/server-security-monitor/*.sh
# Set the minimum required access permissions.
sudo chmod 0644 /etc/systemd/system/{aide-check,rkhunter-check}.{service,timer}

# Check the syntax of the shell script without executing it.
sudo bash -n /usr/local/libexec/server-security-monitor/*.sh
# Validate the systemd unit file before activation.
sudo systemd-analyze verify \
  /etc/systemd/system/aide-check.service \
  /etc/systemd/system/rkhunter-check.service
# Manage a systemd unit or read its actual state.
sudo systemctl daemon-reload
# Manage a systemd unit or read its actual state.
sudo systemctl enable --now aide-check.timer rkhunter-check.timer

A manual run checks not only the syntax but the whole scanning and notification path. After it finishes, read the Result, the ExecMainStatus and the recent journal of both services. Start the real services:

# Manage a systemd unit or read its actual state.
sudo systemctl start aide-check.service
# Manage a systemd unit or read its actual state.
sudo systemctl start rkhunter-check.service
# Manage a systemd unit or read its actual state.
sudo systemctl show aide-check.service rkhunter-check.service \
  -p Result -p ExecMainStatus
# Read the systemd journal events that belong to the service.
sudo journalctl -u aide-check.service -u rkhunter-check.service \
  --since '1 hour ago' --no-pager

After a successful finish a service of the oneshot type usually has the state inactive, and that is normal. What matters is Result=success and ExecMainStatus=0.


Part VI. Instant notifications through PAM

14. The send-message and login-alert scripts

We separate the privileged PAM handler from the simple sender: the first builds a safe message from the session data, the second passes it to the shared library. The handler runs separately and always returns success, so a Telegram failure does not change the result of an SSH login or of sudo.

The PAM variables are controlled by the remote login. Never insert them into eval, bash -c or a command line that is being built.

Save the first block as send-message.sh: it is a minimal adapter that takes the already composed text as a single argument and passes it to the shared function. Strict mode makes a delivery error visible when it is run directly. First create /usr/local/libexec/server-security-monitor/send-message.sh:

#!/usr/bin/env bash
# Enable strict handling of errors and of unset variables.
set -Eeuo pipefail
# Restrict the search for executables to trusted system directories.
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# Source the trusted file with variables or shared functions.
. /usr/local/libexec/server-security-monitor/common.sh
# Send the composed message through the shared hardened function.
tg_send "${1:-}"

Save the code as login-alert.sh: it handles only open_session, escapes all the PAM fields and starts the sender in a separate session. The multi-line msg text and the setsid command are explained as a whole before they begin, so that no comments are inserted inside data or inside lines with \. /usr/local/libexec/server-security-monitor/login-alert.sh:

#!/usr/bin/env bash
# A Telegram failure must not block the login.
set -u
# Restrict the search for executables to trusted system directories.
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

# Test the condition and, if needed, end the step early with a safe result.
[[ ${PAM_TYPE:-} == open_session ]] || exit 0
# Source the trusted file with variables or shared functions.
. /usr/local/libexec/server-security-monitor/common.sh

# Choose the notification label according to the name of the PAM service.
case "${PAM_SERVICE:-unknown}" in
  # For this branch set the icon and a readable name of the event.
  sshd) icon='🔐'; kind='SSH-вход' ;;
  # For this branch set the icon and a readable name of the event.
  sudo) icon='🧨'; kind='sudo' ;;
  # For this branch set the icon and a readable name of the event.
  su)   icon='🔀'; kind='su' ;;
  # For this branch set the icon and a readable name of the event.
  *)    icon='👤'; kind=${PAM_SERVICE:-unknown} ;;
esac

# Set the working value of `user`.
user=$(printf '%s' "${PAM_USER:-?}" | html_escape)
# Set the working value of `source_host`.
source_host=$(printf '%s' "${PAM_RHOST:-local}" | html_escape)
# Set the working value of `service`.
service=$(printf '%s' "${PAM_SERVICE:-unknown}" | html_escape)
# Set the working value of `tty`.
tty=$(printf '%s' "${PAM_TTY:-?}" | html_escape)
# Get the fully qualified host name with a fallback.
host=$(hostname -f 2>/dev/null || hostname)

# Build the notification text from the already escaped fields.
msg="${icon} <b>${kind}</b> на <code>${host}</code>
Пользователь: <code>${user}</code>
Источник: <code>${source_host:-local}</code>
Сервис/TTY: <code>${service} / ${tty}</code>
$(date '+%F %T %Z')"

# The data is passed as a single argv entry and is never re-evaluated by the shell.
/usr/bin/setsid -f \
  /usr/local/libexec/server-security-monitor/send-message.sh \
  "$msg" >/dev/null 2>&1 || true
# Exit the script with the chosen result code.
exit 0

Once both PAM scripts have been saved, assign the owner and the execute permissions and check the syntax without running them. All three continued commands must be copied as a whole.

# Enforce the expected owner and group.
sudo chown root:root \
  /usr/local/libexec/server-security-monitor/{send-message,login-alert}.sh
# Set the minimum required access permissions.
sudo chmod 0750 \
  /usr/local/libexec/server-security-monitor/{send-message,login-alert}.sh
# Check the syntax of the shell script without executing it.
sudo bash -n \
  /usr/local/libexec/server-security-monitor/{send-message,login-alert}.sh

15. Connecting PAM safely

PAM is a chain of authentication and session management modules; the pam_exec.so line will add a run of our script when a session is opened. Before editing we save the working files, connect the handler as optional and check a new login separately, so that the notification does not become a single point of failure for access.

First keep the current root/sudo session open and check that the provider console is available.

# Save a copy of the file or the tree with its original metadata.
sudo cp -a /etc/pam.d/sshd "/etc/pam.d/sshd.bak.$(date +%s)"
# Save a copy of the file or the tree with its original metadata.
sudo cp -a /etc/pam.d/sudo "/etc/pam.d/sudo.bak.$(date +%s)"

This line is saved in the PAM config itself, it is not executed in the shell. session optional starts the notification when a session is opened, but its failure does not change the PAM decision about access. Add to the end of /etc/pam.d/sshd:

session optional pam_exec.so /usr/local/libexec/server-security-monitor/login-alert.sh

Optionally add the same line to /etc/pam.d/sudo. Notifications about every sudo can be noisy; on a server with heavy automation it is better to notify only about SSH.

The keyword must be optional, not required: Telegram being unavailable must not take away access to the server.

Check the new SSH login in a second session. If it does not work, restore the PAM file from the backup through the current session or through the console.


Part VII. The daily digest

16. The security-digest script

The daily digest brings together in one message the Fail2ban bans, the CrowdSec decisions, the SSH errors, the host resources, the need for a reboot and the state of the protective services. For the timer-driven tasks it checks not only that the schedule is enabled but also the result of the last run of the oneshot service; we expect a short report and a successful service status.

Save the block as a standalone security-digest.sh. The embedded Python sits inside a multi-line shell string and parses the CrowdSec JSON; comments inside that fragment would become part of the Python code, so the important actions are explained before the assignment. /usr/local/libexec/server-security-monitor/security-digest.sh:

#!/usr/bin/env bash
# Enable strict handling of errors and of unset variables.
set -Eeuo pipefail
# Restrict the search for executables to trusted system directories.
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# Pin the locale so that output parsing does not depend on the system language.
export LC_ALL=C
# Source the trusted file with variables or shared functions.
. /usr/local/libexec/server-security-monitor/common.sh

# Open a separate file descriptor for the lock file or for passing parameters privately.
exec 9>/run/lock/server-security-digest.lock
# Take a non-blocking lock and do not start a second instance.
flock -n 9 || exit 0

# Get the current status of the Fail2ban SSH jail.
f2b=$(fail2ban-client status sshd 2>/dev/null || true)
# Extract the number of active bans.
banned=$(printf '%s\n' "$f2b" | awk -F: '
  tolower($1) ~ /currently banned/ {gsub(/[^0-9]/,"",$2); print $2; exit}')
# Extract the total number of bans.
total=$(printf '%s\n' "$f2b" | awk -F: '
  tolower($1) ~ /total banned/ {gsub(/[^0-9]/,"",$2); print $2; exit}')

# Set the typical name of the SSH unit.
ssh_unit=sshd
# Check the actual state of a systemd unit.
systemctl cat ssh.service >/dev/null 2>&1 && ssh_unit=ssh
# Count the failed SSH events of the last twenty-four hours.
fails=$(journalctl -u "$ssh_unit" --since '24 hours ago' --no-pager \
  2>/dev/null | grep -ciE \
  'Failed password|Invalid user|authentication failure' || true)

# Get the CrowdSec decisions as machine-readable JSON.
cs_json=$(cscli decisions list -o json 2>/dev/null || true)
# Count the decisions, allowing for the different JSON formats.
cs=$(printf '%s' "$cs_json" | python3 -c '
import json, sys
try:
    data=json.load(sys.stdin)
    if isinstance(data, list):
        # Some versions return alerts with nested decisions.
        print(sum(len(x.get("decisions", [])) if isinstance(x, dict) else 0
                  for x in data))
    elif isinstance(data, dict):
        print(len(data.get("decisions", [])))
    else:
        print("?")
except Exception:
    print("?")')

# Collect the usage of the random access memory.
mem=$(free -m | awk '/^Mem:/{print $3"/"$2" MB"}')
# Get how full the root file system is.
disk=$(df -P / | awk 'NR==2{print $5}')
# Read the load average values directly from `/proc/loadavg`.
read -r l1 l5 l15 _ </proc/loadavg

# Test the condition and choose the branch to run next.
if command -v needs-restarting >/dev/null 2>&1; then
  # Test the condition and choose the branch to run next.
  if needs-restarting -r >/dev/null 2>&1; then
    # Build a readable status for whether a reboot is needed.
    reboot_state='✅ ребут не нужен'
  # If the condition does not hold, fall back to the safe alternative.
  else
    # Build a readable status for whether a reboot is needed.
    reboot_state='♻️ <b>НУЖЕН РЕБУТ</b>'
  fi
# If the previous condition is false, test the alternative marker.
elif [[ -e /var/run/reboot-required ]]; then
  # Build a readable status for whether a reboot is needed.
  reboot_state='♻️ <b>НУЖЕН РЕБУТ</b>'
# If the condition does not hold, fall back to the safe alternative.
else
  # Build a readable status for whether a reboot is needed.
  reboot_state='✅ ребут не требуется или не определён'
fi

# Declare the check of the timer and of the result of the last oneshot run.
check_timer() {
  # Set the working value of `timer`.
  local timer=$1 service=$2
  # Check the actual state of a systemd unit.
  systemctl is-active --quiet "$timer" &&
  systemctl is-enabled --quiet "$timer" &&
  [[ $(systemctl show "$service" -p Result --value 2>/dev/null) == success ]] &&
  [[ $(systemctl show "$service" -p ExecMainStatus --value 2>/dev/null) == 0 ]]
}

# Create the array with the self-check results of the protective services.
health=()
# Add the check result of the AIDE timer and service to the digest.
check_timer aide-check.timer aide-check.service \
  && health+=('AIDE✅') || health+=('AIDE⚠️')
# Add the check result of the rkhunter timer and service to the digest.
check_timer rkhunter-check.timer rkhunter-check.service \
  && health+=('rkhunter✅') || health+=('rkhunter⚠️')
# Check the actual state of a systemd unit.
systemctl is-active --quiet crowdsec crowdsec-firewall-bouncer \
  && health+=('CrowdSec✅') || health+=('CrowdSec⚠️')
# Check the actual state of a systemd unit.
systemctl is-active --quiet fail2ban \
  && health+=('Fail2ban✅') || health+=('Fail2ban⚠️')

# Get the fully qualified host name with a fallback.
host=$(hostname -f 2>/dev/null || hostname)
# Send the composed message through the shared hardened function.
tg_send "📋 <b>Дайджест ${host}</b> — $(date '+%F')
🔐 SSH забанено: ${banned:-?} (всего ${total:-?})
🛡 CrowdSec активных решений: ${cs:-?}
❌ Неудачных SSH за сутки: ${fails:-0}
🧠 RAM: ${mem} | 💾 Диск /: ${disk}
📈 load: ${l1}, ${l5}, ${l15}
${reboot_state}
🩺 ${health[*]}"

This service of the oneshot type runs the digest collection once the network and the protective services are up, restricts the permissions of the files it creates and gives the task two minutes. Save it at the specified system path. The service /etc/systemd/system/security-digest.service:

[Unit]
Description=Daily security digest to Telegram
After=network-online.target crowdsec.service fail2ban.service
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/libexec/server-security-monitor/security-digest.sh
Nice=10
IOSchedulingClass=idle
UMask=0077
TimeoutStartSec=2min

The timer schedules the digest at about 09:00, adds a random delay and catches up a missed run. Once it is enabled it must appear in systemctl list-timers. The timer /etc/systemd/system/security-digest.timer:

[Unit]
Description=Daily security digest to Telegram

[Timer]
OnCalendar=*-*-* 09:00:00
RandomizedDelaySec=900
Persistent=true

[Install]
WantedBy=timers.target

First give the script execute permissions and check the service, then reload the systemd configuration, enable the timer and start the service by hand. The resulting status must be successful and the schedule must be visible in the list of timers. Activate and verify with a real run:

# Set the minimum required access permissions.
sudo chmod 0750 /usr/local/libexec/server-security-monitor/security-digest.sh
# Validate the systemd unit file before activation.
sudo systemd-analyze verify /etc/systemd/system/security-digest.service
# Manage a systemd unit or read its actual state.
sudo systemctl daemon-reload
# Manage a systemd unit or read its actual state.
sudo systemctl enable --now security-digest.timer
# Manage a systemd unit or read its actual state.
sudo systemctl start security-digest.service
# Manage a systemd unit or read its actual state.
sudo systemctl show security-digest.service -p Result -p ExecMainStatus
# Manage a systemd unit or read its actual state.
sudo systemctl list-timers --all | grep security-digest

Part VIII. Updates and rotation

17. Automatic security updates

We automate only the installation of security updates, keeping the reboot and the acceptance of a new trusted AIDE database (baseline) under manual control. On Debian/Ubuntu this is done by unattended-upgrades, on RHEL-like systems by the dnf-automatic-install timer; a dry run or the list of timers must confirm that the setup works.

Debian/Ubuntu

The dpkg-reconfigure dialogue enables the standard automatic update mechanism, after which the service is started immediately. The dry run shows the selected sources and packages without changing the system; we expect no errors and only the allowed security update repositories.

# Configure the automatic security updates of Debian/Ubuntu.
sudo dpkg-reconfigure --priority=low unattended-upgrades
# Manage a systemd unit or read its actual state.
sudo systemctl enable --now unattended-upgrades
# Perform a verbose dry run without installing any updates.
sudo unattended-upgrade --dry-run --debug

Check /etc/apt/apt.conf.d/50unattended-upgrades and make sure that only the expected sources are enabled. Leave the automatic reboot switched off by default; the digest will report /var/run/reboot-required.

RHEL-like

In the [commands] section leave the installation of security updates only and forbid the automatic reboot. These lines are saved in an INI file, they are not executed in the shell. In /etc/dnf/automatic.conf:

[commands]
upgrade_type = security
download_updates = yes
apply_updates = yes
reboot = never

Enable the standard DNF timer and make sure that systemd shows its schedule. This confirms the automatic start, but the need for a reboot is still controlled by the digest.

# Manage a systemd unit or read its actual state.
sudo systemctl enable --now dnf-automatic-install.timer
# Manage a systemd unit or read its actual state.
sudo systemctl list-timers --all | grep dnf-automatic

Do not update the AIDE database automatically right after packages have been installed. First match the changes against the package manager log.

18. logrotate

logrotate limits the growth of the local reports: it archives them daily, keeps 30 generations and creates the new files with 0600 permissions. The check in debug mode does not rotate anything, but it shows that the rule is read and that it selects the expected root-only logs.

Save the rule in the specified file: the patterns cover the reports of both scanners, while su and create preserve the owner and the restrictive permissions. After 30 rotations the old archives will be deleted. /etc/logrotate.d/server-security-monitor:

/var/log/server-security-monitor/aide/*.log /var/log/server-security-monitor/rkhunter/*.log {
    daily
    rotate 30
    compress
    delaycompress
    missingok
    notifempty
    su root root
    create 0600 root root
}

Both commands are safe on a running server: the first parses the rule without changing any files, the second shows the actual modes and owners of the reports. Only 0600 root:root files are expected. A check without rotation:

# Check the logrotate rule in debug mode without rotating anything.
sudo logrotate -d /etc/logrotate.d/server-security-monitor
# Check the permissions, the owners, the sizes and the paths of the local reports.
sudo find /var/log/server-security-monitor -type f \
  -printf '%m %u:%g %s %p\n'

All the reports must be 0600 root:root.


Part IX. The final check

19. The final check

Now we check the assembled system as a whole instead of checking that the individual packages are present. The commands verify the effective configuration, the readiness of the daemons, the applied decisions, the results of the last runs, the schedules and the permissions on the secrets; all the checks must pass with no exposed tokens and no unsuccessful services.

SSH and the firewall

We check the syntax and the parameters that sshd really applies, then the listening sockets and the permanent firewalld rules. This must confirm that the hardened SSH is available only on the expected port.

# Check the syntax or the effective sshd configuration.
sudo sshd -t
# Check the syntax or the effective sshd configuration.
sudo sshd -T | grep -E \
  '^(port|permitrootlogin|passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication|maxauthtries) '
# Check that sshd really listens on the expected TCP port.
sudo ss -ltnp
# Change or verify a permanent firewalld rule.
sudo firewall-cmd --list-all

Expected:

  • direct root login is forbidden;
  • passwords and keyboard-interactive authentication are switched off;
  • public keys are enabled;
  • the firewall contains only the necessary services and the actual SSH port.

Fail2ban

The client must answer pong, show an active sshd jail and pass a repeated configuration check. This way we verify the readiness of the process, not only the state of the service.

# Check the configuration, the readiness or the status of Fail2ban.
sudo fail2ban-client ping
# Check the configuration, the readiness or the status of Fail2ban.
sudo fail2ban-client status
# Check the configuration, the readiness or the status of Fail2ban.
sudo fail2ban-client status sshd
# Check the configuration, the readiness or the status of Fail2ban.
sudo fail2ban-client -t

CrowdSec

We verify the work of the detection mechanism and of the bouncer, the arrival of events, the list of decisions and the registration of the enforcer. The presence of the CrowdSec tables in nftables confirms that the decisions can affect traffic.

# Manage a systemd unit or read its actual state.
sudo systemctl is-active crowdsec crowdsec-firewall-bouncer
# Manage CrowdSec objects or read their actual state.
sudo cscli metrics
# Manage CrowdSec objects or read their actual state.
sudo cscli decisions list
# Manage CrowdSec objects or read their actual state.
sudo cscli bouncers list
# Inspect the actual nftables ruleset.
sudo nft list ruleset | grep -i crowdsec

Monitoring

We forcibly start all three services of the oneshot type and read their final statuses, after which we check the enabled timers and the next runs. The normal result is Result=success, ExecMainStatus=0 and three active schedules.

# Manage a systemd unit or read its actual state.
sudo systemctl start aide-check.service
# Manage a systemd unit or read its actual state.
sudo systemctl start rkhunter-check.service
# Manage a systemd unit or read its actual state.
sudo systemctl start security-digest.service

# Manage a systemd unit or read its actual state.
sudo systemctl show \
  aide-check.service rkhunter-check.service security-digest.service \
  -p Result -p ExecMainStatus

# Manage a systemd unit or read its actual state.
sudo systemctl is-enabled \
  aide-check.timer rkhunter-check.timer security-digest.timer
# Manage a systemd unit or read its actual state.
sudo systemctl list-timers --all | grep -E \
  'aide-check|rkhunter-check|security-digest'

Files and secrets

The permissions must restrict reading of the Telegram configuration and of the reports, and a search by the shape of the token must find no matches in the scripts and in the service files. The command prints only metadata and paths, not the values of the secrets.

# Print only the permissions, the owner and the name of the protected file.
sudo stat -c '%A %U:%G %n' \
  /etc/server-security-monitor/telegram.env \
  /usr/local/libexec/server-security-monitor/*.sh \
  /var/log/server-security-monitor

# Extract only the required lines of the configuration or of the log.
sudo grep -RIlE \
  '[0-9]{6,12}:[A-Za-z0-9_-]{20,}' \
  /usr/local/libexec/server-security-monitor \
  /etc/systemd/system || true

The last command must not find any Telegram bot tokens in the scripts or in the service files.

20. Negative tests

A routine check does not show the behaviour during a failure, so we deliberately simulate an unavailable Telegram config and a busy lock file. We expect a controlled error of the sender without PAM being blocked, and a silent refusal of the second AIDE instance without a parallel scan.

Check the Telegram failure by temporarily pointing to a non-existent config through a variable set for the test command only:

# Substitute the config path for this run only, simulating a failure.
sudo SSM_CONF=/nonexistent \
  /usr/local/libexec/server-security-monitor/send-test.sh

The command must finish with an error, but login through SSH/PAM must not be blocked.

The first command holds the lock file for 15 seconds in the background, and the second starts the AIDE service in the meantime. The wrapper must see the busy lock and exit without a second scanner. Check that a parallel run is blocked:

# Hold the AIDE lock in a background process for 15 seconds.
sudo flock /run/lock/server-security-aide.lock sleep 15 &
# Manage a systemd unit or read its actual state.
sudo systemctl start aide-check.service

The second run must finish quietly, without creating a parallel heavy check.

Do not create a test change in a system file. For AIDE use a separate test path enabled in advance, for example /root/aide-test, then delete it and investigate both detected changes. After the test the database is not updated without a manual review.


Part X. Investigation and maintenance

21. What to do about an AIDE alert

An AIDE alert reports a divergence from the trusted database, but it does not explain the cause. First we preserve the evidence and establish the owner of the file, the package, the extended permissions (capabilities), the SELinux context and the connection with updates; we change the trusted database (baseline) only after all the paths have been confirmed.

  1. Do not run aide --update.
  2. Save the full report and the time of the event. For every path, the set of checks below shows the metadata, the package owner, the extended permissions and the SELinux context without changing the system. A missing package or a missing individual command is acceptable, so the diagnostic lines use || true.
  3. For every path run:
# Print only the permissions, the owner and the name of the protected file.
sudo stat /PATH/TO/FILE
# Check whether the object belongs to an RPM package.
sudo rpm -qf /PATH/TO/FILE 2>/dev/null || true
# Check whether the object belongs to a Debian package.
sudo dpkg -S /PATH/TO/FILE 2>/dev/null || true
# Read the Linux capabilities assigned to the file.
sudo getcap /PATH/TO/FILE 2>/dev/null || true
# Show the permissions, the owner and the SELinux context of the object.
sudo ls -lZ /PATH/TO/FILE 2>/dev/null || true

The following block reads the last DNF transaction or the APT history without changing the system. A matching time and the expected package help to explain the change, but on their own they do not replace an inspection of the file.

  1. Match the time against the updates:
# Show the details of the last DNF transaction.
sudo dnf history info last 2>/dev/null || true
# Extract only the required lines of the configuration or of the log.
sudo grep -hE ' upgrade | install | remove ' /var/log/apt/history.log 2>/dev/null || true
  1. Check the SSH and sudo logs and the system journal around the time of the change.
  2. If the change is expected, create a backup of the old database and perform the update by hand.
  3. If the origin is unknown, do not destroy the evidence: restrict the network, save the logs and investigate from a trusted host.

Updating the AIDE database safely

Updating the trusted database (baseline) is acceptable only after an investigation: first we repeat the check, create a new database, keep the old one and install the new one with 0600 permissions. A final check without changes confirms that exactly the reviewed version of the state has been accepted.

The paths differ between distributions. For the RHEL-like variant:

# Check the configuration, create the baseline or compare the current state with AIDE.
sudo aide --check
# Check the configuration, create the baseline or compare the current state with AIDE.
sudo aide --update
# Check that the AIDE update created the new database before installing it.
sudo test -f /var/lib/aide/aide.db.new.gz
# Save a copy of the file or the tree with its original metadata.
sudo cp -a /var/lib/aide/aide.db.gz \
  "/var/lib/aide/aide.db.gz.bak.$(date +%s)"
# Create a directory or install a file with the given owner and permissions.
sudo install -o root -g root -m 0600 \
  /var/lib/aide/aide.db.new.gz /var/lib/aide/aide.db.gz
# Check the configuration, create the baseline or compare the current state with AIDE.
sudo aide --check

The last command must return 0. Do not redirect the update report into a directory that AIDE checks in the same run: that creates self-generating changes.

22. rkhunter and false positives

An rkhunter warning is a reason to match the object against the packages and the logs, not to declare a compromise automatically. Separately we rule out a double schedule, where the packaged cron job and our systemd timer start the same scanner and create extra load or a local mail queue.

rkhunter is a heuristic tool, not a proof of compromise. Check:

  • whether the file belongs to a package;
  • changes after an update;
  • the permissions, the owner, the extended permissions (capabilities) and the SELinux context;
  • the presence of a duplicate schedule.

Some packages install /etc/cron.daily/rkhunter while you additionally create a systemd timer. This leads to a double run and sometimes to a local mail queue. The block shows whether the packaged cron job is present, whether the timer we created is enabled and what the result of its last service run was; this way the duplication can be proved before one of the schedules is switched off. Some packages install /etc/cron.daily/rkhunter while you additionally create a systemd timer. This leads to a double run and sometimes to a local mail queue. Check:

# Check whether the packaged cron job starts another copy of rkhunter.
sudo run-parts --test /etc/cron.daily | grep -i rkhunter || true
# Manage a systemd unit or read its actual state.
sudo systemctl is-enabled rkhunter-check.timer
# Manage a systemd unit or read its actual state.
sudo systemctl show rkhunter-check.service -p Result -p ExecMainStatus

If the timer we created fully replaces the packaged cron job, use the documented option for disabling it that the package provides. If there is none, it is acceptable to remove the execute permission from that one cron file only, writing down how to roll this back. A package update may restore that permission.

23. Monthly maintenance

Once a month we re-validate the access configuration, update the index of CrowdSec scenarios and review the systemd errors, the schedules and the permissions of the reports. Such control detects drift after package updates; it does not apply new CrowdSec scenarios automatically and does not accept AIDE changes.

# Check the syntax or the effective sshd configuration.
sudo sshd -t
# Check the configuration, the readiness or the status of Fail2ban.
sudo fail2ban-client -t
# Manage CrowdSec objects or read their actual state.
sudo cscli hub update
# Manage CrowdSec objects or read their actual state.
sudo cscli collections list
# Manage CrowdSec objects or read their actual state.
sudo cscli metrics
# Manage a systemd unit or read its actual state.
sudo systemctl --failed
# Manage a systemd unit or read its actual state.
sudo systemctl list-timers --all
# Read the systemd journal events that belong to the service.
sudo journalctl -p 0..3 --since '30 days ago'
# Check the permissions, the owners, the sizes and the paths of the local reports.
sudo find /var/log/server-security-monitor -type f \
  -printf '%m %u:%g %TY-%Tm-%Td %p\n'

Do not run an automatic cscli hub upgrade without reading the release notes and making a backup of the configuration.


Part XI. Rollback and removal

24. Fast SSH/PAM rollback

A rollback is a return to a working configuration saved in advance. It is performed from a privileged session that is still open or from the provider console: SSH and PAM are restored, the syntax is checked and only then the daemon configuration is reloaded.

Run the block from a channel that does not depend on the SSH session being fixed. It puts the saved trees back in place, checks sshd and reloads the configuration only after a success. Through an open emergency session or the provider console:

# Save a copy of the file or the tree with its original metadata.
sudo cp -a "$BACKUP/ssh/." /etc/ssh/
# Save a copy of the file or the tree with its original metadata.
sudo cp -a "$BACKUP/pam.d/." /etc/pam.d/
# Check the syntax or the effective sshd configuration.
sudo sshd -t
# Manage a systemd unit or read its actual state.
sudo systemctl reload sshd 2>/dev/null || sudo systemctl reload ssh

If the BACKUP variable has already been lost, find the directory by hand in /root/server-security-backup-*.

25. Disabling the monitoring layer

We perform the removal from the schedules towards the files: first we stop the timers, then we remove the services and the scripts, we decide separately what to do with the secrets and the reports, and at the end we check the firewall. This order leaves no systemd tasks referring to files that have already been deleted, and it does not destroy the evidence too early.

# Manage a systemd unit or read its actual state.
sudo systemctl disable --now \
  aide-check.timer rkhunter-check.timer security-digest.timer
# Remove the unit and timer files of the disabled monitoring layer.
sudo rm -f /etc/systemd/system/{aide-check,rkhunter-check,security-digest}.{service,timer}
# Manage a systemd unit or read its actual state.
sudo systemctl daemon-reload

Before removing the PAM handler, first delete only the line with server-security-monitor/login-alert.sh, check a new SSH login and only then delete the scripts. The next block removes the shared library and the rotation rule, so after it the notifications and the scheduled maintenance of these logs will stop.

# Remove the library and the scripts of the monitoring layer.
sudo rm -rf /usr/local/libexec/server-security-monitor
# Remove the report rotation rule that is no longer used.
sudo rm -f /etc/logrotate.d/server-security-monitor

Do not delete /etc/server-security-monitor/telegram.env and the reports until you have confirmed that they are no longer needed. Destroy the secrets deliberately:

# Remove the local Telegram secrets file after confirmation.
sudo rm -f /etc/server-security-monitor/telegram.env

Before removing the packages, stop the bouncer, the detection mechanism and Fail2ban, then save the output of the actual nftables and firewalld rules. The result is needed in order to make sure that stopping the enforcers has not closed the administrative port and has not left any unexpected rules. Remove CrowdSec and Fail2ban with the standard package manager only after the bouncer has been stopped and the firewall has been checked:

# Manage a systemd unit or read its actual state.
sudo systemctl disable --now crowdsec-firewall-bouncer crowdsec fail2ban
# Inspect the actual nftables ruleset.
sudo nft list ruleset
# Change or verify a permanent firewalld rule.
sudo firewall-cmd --list-all

How to check the final setup

The list below describes the expected state of your server after the guide has been followed. Verify every item: this is how you make sure that the settings are not merely saved in files but really applied, that the checks run on schedule, and that the results and the notifications are available for a later investigation.

  • SELinux runs in Enforcing mode, if it is used in the chosen distribution;
  • SSH listens only on the chosen non-standard port, and PermitRootLogin no, PasswordAuthentication no, KbdInteractiveAuthentication no and MaxAuthTries 3 are in force in the resulting configuration;
  • firewalld is active and opens only the declared web services and the actual SSH port;
  • Fail2ban serves sshd and recidive, uses the systemd journal and the numeric SSH port;
  • the CrowdSec detection mechanism and the firewall bouncer are active, and the bouncer applies the decisions through nftables;
  • the AIDE and rkhunter checks, the daily status report and the security updates are started by systemd timers with a random delay and Persistent=true;
  • the last runs of the services of the oneshot type have Result=success and ExecMainStatus=0;
  • the Telegram configuration has 0600 root:root permissions;
  • the local AIDE and rkhunter reports are stored with 0600 root:root permissions and are rotated;
  • the daily report and the PAM notifications use limited network timeouts;
  • the working AIDE database exists, and the temporary new database does not remain after the reviewed trusted database (baseline) has been accepted.

Check separately where the crowdsec-firewall-bouncer executable comes from. This is needed in order to know which package is responsible for its updates and its provenance. Of the two commands below, only the package manager of your distribution should return one matching package; the second command is expected to find no owner. If both commands return nothing, the enforcer has been installed outside the package manager and its updating has to be controlled separately.

# Check whether the bouncer belongs to an installed RPM package.
rpm -qf "$(command -v crowdsec-firewall-bouncer)" 2>/dev/null || true
# Check whether the bouncer belongs to an installed Debian package.
dpkg -S "$(command -v crowdsec-firewall-bouncer)" 2>/dev/null || true

Conclusion

After the setup the server does not become invulnerable, but the system consistently reduces the probability of a successful attack, detects suspicious activity, blocks known attack sources and informs the administrator about the events that need to be checked:

  • SSH accepts key-based login, while direct root login and password authentication are disabled;
  • the firewall leaves available only the necessary services and the actual SSH port;
  • Fail2ban automatically blocks repeated failed login attempts;
  • CrowdSec detects broader attack scenarios, and the bouncer applies the blocking decisions;
  • AIDE detects changes to important files, and rkhunter adds a separate heuristic signal;
  • security updates shorten the time during which the server stays vulnerable to known problems;
  • Telegram delivers short notifications, while the full investigation data is stored locally;
  • systemd timers, file locks, timeouts and checks of exit codes help to notice a failure of the monitoring system itself in time.

The main thing is not simply to install the components, but to check regularly that they really work: that they listen on the right ports, apply the decisions, finish the checks successfully, store the reports and deliver the notifications without exposing the secrets.

Official sources

Заходите в группу Telegram Join the Telegram group
Если есть вопросы или хотите пообщаться, то заходите в мою группу Telegram. If you have questions or want to chat, join my Telegram group.