Полная защита SSH на Ubuntu: ключи, Fail2ban и firewall

Сервер, доступный из интернета, получает первые попытки перебора паролей через считанные минуты после появления IP-адреса в сети. SSH при этом остаётся точкой входа номер один.
Материал описывает production-ready конфигурацию SSH для Ubuntu и Debian: вход по ключу, запрет паролей на уровне протокола, встроенное ограничение частоты подключений OpenSSH, Fail2ban, межсетевой экран, отсечение сканирующих адресов и автоматические обновления безопасности. Отдельно разобраны три места, на которых спотыкаются чаще всего: порядок слияния конфигурации sshd из нескольких файлов, различия в работе службы при активации сокетом и механизм штрафов PerSourcePenalties, появившийся в OpenSSH 9.8 и включённый по умолчанию.
Вся настройка ведётся под root. На машине с единственным администратором это оправдано и убирает целый класс ошибок, когда ключ лежит у одной учётной записи, а вход разрешён другой. Плата — отсутствие разделения «повседневная работа / повышение привилегий» и невозможность установить по журналам, кто именно выполнил команду. С момента, когда доступ к серверу получает второй человек, требуется переход к персональным учётным записям с sudo.
Список допущенных учётных записей в конфигурации намеренно не перечисляется. Директива AllowUsers выглядит как усиление защиты, но лишь дублирует то, что и так определяется наличием ключа, зато превращается в мину при добавлении второго администратора: имя забывают вписать, и доступ теряется. Круг допущенных задаёт файл authorized_keys, способ входа — директива AuthenticationMethods.
Главный принцип материала: ни одна команда не меняет способ запуска службы SSH. Именно на этом теряют доступ чаще всего, а выигрыш — косметический. Служба остаётся в режиме, настроенном провайдером образа, и вся инструкция рассчитана на оба возможных режима.
Тем, кто разворачивает серверы регулярно, пошаговое чтение нужно один раз. Готовый блок со всеми командами, который выполняется целиком за одну вставку в терминал, находится в разделе «Всё одним блоком». Разделы выше объясняют, что именно он делает и почему.
Почему нельзя оставлять настройки по умолчанию
Стандартная конфигурация OpenSSH на многих образах виртуальных серверов допускает вход по паролю. Это создаёт постоянную фоновую нагрузку от переборщиков и оставляет реальный шанс на компрометацию: достаточно одного слабого пароля. На облачном образе ситуация хуже — имя учётной записи атакующему известно заранее, это root.
Без настройки инфраструктура получает непрерывные попытки подбора (сотни и тысячи в сутки), разрастание журналов, рост времени отклика при массовых атаках и риск полного захвата системы при удачном подборе.
Шаг 0. Аварийный доступ и рабочие условия
Все изменения выполняются на удалённой машине, и ошибка в конфигурации способна отрезать доступ. До начала работ требуется выполнение трёх условий.
Первое. У панели управления провайдера есть аварийная консоль (VNC, KVM, веб-консоль), путь к ней и пароль известны заранее. Это единственный обратный путь, если SSH перестанет отвечать.
Второе. На всё время настройки открыт второй терминал с рабочим подключением к серверу. Уже установленная сессия переживает изменения конфигурации и остаётся страховкой до тех пор, пока новые настройки не проверены отдельным подключением.
Третье. Работа ведётся в сессии root. Во всех командах ниже sudo не используется намеренно. Переход в сессию root из обычной учётной записи — командой sudo -i.
Шаг 1. В каком режиме работает служба SSH
Это не подготовка к переключению — переключение не выполняется. От режима зависят три команды в дальнейших шагах, и выяснить его удобнее сразу.
ss -tlnp | grep -E ':22\b'Определяющий признак — имя процесса в конце строки:
users:(("systemd",pid=1,...))— активация сокетом. Порт слушает systemd, отдельный процесс sshd поднимается на каждое входящее соединение. Так по умолчанию устроены Ubuntu 22.10 и новее, включая 24.04;users:(("sshd",pid=...))— классический демон. Один постоянно работающий процесс держит порт сам. Так работают Debian и Ubuntu до 22.10.
Результат понадобится дважды. Дополнительно проверяется состояние обеих единиц:
systemctl is-active ssh ssh.socketПри активации сокетом ответ обычно inactive и active, у классического демона — наоборот. Встречаются гибридные образы, где обе единицы связаны зависимостями нестандартным образом. Именно поэтому они не затрагиваются: работающая конфигурация провайдера не ломается ради удобства набора команд.
Шаг 2. Генерация ключа ed25519
Алгоритм ed25519 даёт лучшее сочетание стойкости, скорости проверки подписи и компактности. RSA остаётся допустимым только при длине от 4096 бит и только там, где ed25519 не поддерживается устаревшим оборудованием.
Ключ генерируется на клиентской машине, а не на сервере. Команда одинакова для Linux, macOS и Windows (PowerShell):
ssh-keygen -t ed25519 -C "рабочий-ноутбук" -f ~/.ssh/id_ed25519Создаются два файла: id_ed25519 — закрытый ключ, остаётся только у владельца; id_ed25519.pub — открытый ключ, он и передаётся на серверы.
На запрос пароля к ключу рекомендуется задать непустую парольную фразу: при утечке файла она даёт время на отзыв доступа. Чтобы не вводить фразу при каждом подключении, ключ добавляется в агент:
eval "$(ssh-agent -s)"ssh-add ~/.ssh/id_ed25519Резервный ключ вместо второй учётной записи
При работе только под root вторая учётная запись страховкой не служит — её роль берёт на себя второй ключ. Отдельный ключ заводится на другом устройстве либо хранится в надёжном месте как резервный. Потеря рабочего ноутбука в этом случае не отрезает доступ и не требует аварийной консоли:
ssh-keygen -t ed25519 -C "резервный" -f ~/.ssh/id_ed25519_backupОба открытых ключа добавляются на сервер отдельными строками — об этом следующий шаг.
Шаг 3. Ключи в authorized_keys и права на файлы
Это центральный шаг всей настройки. Поскольку явного списка учётных записей в конфигурации не будет, именно содержимое файлов authorized_keys определяет круг допущенных. Всё остальное — рамки, внутри которых этот список работает.
На облачном образе ключ, указанный при создании машины, провайдер уже разместил в /root/.ssh/authorized_keys. Поэтому шаг начинается не с создания файла, а с проверки его содержимого:
cat /root/.ssh/authorized_keysКаждая строка — один ключ. Если нужного ключа в файле нет либо требуется добавить резервный, файл открывается в редакторе:
nano /root/.ssh/authorized_keysСодержимое .pub-файла вставляется целиком, одной строкой, без переносов. Строка начинается с ssh-ed25519 и заканчивается комментарием, указанным при генерации. Сохранение — Ctrl+O, Enter, выход — Ctrl+X.
Заодно проверяется наличие чужих строк — на бывшей в употреблении машине или после чьей-то временной помощи там оседают лишние ключи. Каждая строка означает работающий доступ, и незнакомый комментарий в конце строки — достаточный повод её удалить.
Пути здесь и далее указываются полностью, а не через ~. Это не педантизм: при выполнении команды в сессии другой учётной записи ~ развернётся в другой каталог, и ключ уйдёт не туда. Обнаруживается такая ошибка обычно уже после потери доступа.
Владелец и права — sshd откажется использовать ключ, если файлы доступны на запись посторонним:
chown -R root:root /root/.sshchmod 700 /root/.sshchmod 600 /root/.ssh/authorized_keyschmod 700 /rootПоследняя команда часто выпадает из инструкций, а без неё возникает классическое «ключ правильный, а вход не проходит». При включённом StrictModes (значение по умолчанию — yes) sshd молча отказывается читать authorized_keys, если сам домашний каталог доступен на запись группе или всем.
Проверка результата:
ls -ld /root /root/.ssh /root/.ssh/authorized_keysОжидается владелец root во всех трёх строках и права drwx------, drwx------, -rw-------.
Подключение из отдельного окна терминала:
ssh root@<server_ip>Если вход по ключу не проходит, причину покажет подробный вывод клиента:
ssh -vvv root@<server_ip>Дальнейшие шаги выполняются только после того, как вход по ключу заработал.
Шаг 4. Как sshd читает конфигурацию
Ключевой раздел материала. В современных Ubuntu и Debian в начале файла /etc/ssh/sshd_config стоит директива подключения:
grep -n Include /etc/ssh/sshd_configТипичный ответ — 12:Include /etc/ssh/sshd_config.d/*.conf. Отсюда следуют два принципиальных момента, из-за которых привычная правка основного файла часто не даёт результата:
- Для каждого параметра sshd применяет первое встреченное значение. Не последнее, а именно первое. Всё, что встретится ниже по тексту для того же параметра, игнорируется без единого предупреждения.
- Директива Include стоит в начале файла, поэтому содержимое каталога
sshd_config.d/читается раньше остальной части основной конфигурации. Внутри каталога файлы подключаются в алфавитном порядке:00-*раньше50-*,50-*раньше99-*.
Следствие, обратное интуиции: приоритет у файла с меньшим номером, а не с большим. И любой файл из sshd_config.d/ перекрывает основной sshd_config.
Что уже лежит в каталоге
grep -r '' /etc/ssh/sshd_config.d/Пример реального вывода:
/etc/ssh/sshd_config.d/50-cloud-init.conf:PasswordAuthentication yes
/etc/ssh/sshd_config.d/60-cloudimg-settings.conf:PasswordAuthentication noЗдесь выигрывает 50-cloud-init.conf со значением yes: он читается первым, и запрет из файла с номером 60 не применяется. Правка основного sshd_config в такой ситуации не даёт ничего — пароли остаются разрешёнными, хотя в файле написано no.
Обратная ситуация тоже встречается: на образах Ubuntu Cloud файл 60-cloudimg-settings.conf запрещает пароли ещё до всех правок. Именно поэтому утилита ssh-copy-id на таких машинах не работает — ей нужен парольный вход, чтобы записать ключ. Ключ добавляется вручную, как в шаге 3.
Свой файл с наивысшим приоритетом
Вместо правки основного файла и файлов cloud-init (последние перезаписываются при перезагрузке) создаётся отдельный файл. Он читается первым и гарантированно задаёт все критичные параметры:
nano /etc/ssh/sshd_config.d/00-hardening.confСодержимое файла:
# --- Аутентификация: только ключи, для всех учётных записей ---
AuthenticationMethods publickey
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitEmptyPasswords no
PermitRootLogin prohibit-password
StrictModes yes
# --- Ограничение попыток входа ---
MaxAuthTries 6
LoginGraceTime 30
# --- Ограничение каналов внутри соединения ---
MaxSessions 10
# --- Отключение неиспользуемых возможностей ---
X11Forwarding no
PermitTunnel no
AllowAgentForwarding no
# --- Журналирование ---
LogLevel VERBOSE
# --- Разрыв зависших сессий ---
ClientAliveInterval 300
ClientAliveCountMax 2Права на файл:
chmod 644 /etc/ssh/sshd_config.d/00-hardening.confПояснения к параметрам:
AuthenticationMethods publickeyобъявляет единственный допустимый способ проверки подлинности. Всё остальное отсекается на уровне протокола, независимо от настроек PAM и от того, что вернёт cloud-init в свой файл. Это самая надёжная строка в файле — и одновременно единственная, которую придётся менять при переходе на двухфакторную аутентификацию (тогдаpublickey,keyboard-interactive);PasswordAuthentication noзакрывает прямую проверку пароля, аKbdInteractiveAuthentication no— обходной путь через PAM, по которому пароль проходит даже при выключенной предыдущей директиве. Классическая ловушка: пароли запрещены, а вход по ним продолжает работать;KbdInteractiveAuthentication— актуальное имя параметра; староеChallengeResponseAuthenticationобъявлено устаревшим начиная с OpenSSH 8.7 и лишь принимается как синоним;PermitRootLogin prohibit-passwordразрешает вход root только по ключу. Значение выбрано вместоyesне для красоты: это третий, независимый рубеж на том же направлении;StrictModes yes— значение по умолчанию, указано явно, чтобы связь между правами на/rootиз шага 3 и работой ключа была видна прямо в конфигурации;MaxAuthTries 6, а не 3. Каждый ключ, предложенный клиентом серверу, считается отдельной попыткой. Если в агенте лежит четыре-пять ключей, до нужного дело не доходит:Received disconnect: Too many authentication failures. Радикальнее эта проблема решается на стороне клиента — см. врезку ниже;LoginGraceTime 30— время на завершение аутентификации; сокращение со 120 секунд по умолчанию уменьшает число одновременно висящих сессий у переборщиков и снижает паразитное потребление ресурсов при массовых атаках;MaxSessions 10вынесен в отдельный раздел намеренно: он ограничивает число каналов внутри уже установленного соединения, а не попытки входа. К защите от подбора отношения не имеет. Значение по умолчанию тоже 10, и опускать его ниже не стоит: при мультиплексировании соединений и удалённой разработке каналы расходуются быстро — терминалы, проброшенные порты, служебные каналы редактора;PermitTunnel noотключает туннели уровня tun/tap, то есть VPN поверх SSH. Обычный проброс портов (-L,-R,-D) эта директива не затрагивает — за него отвечаетAllowTcpForwarding, и он намеренно остаётся включённым, иначе сломаются туннели к базам данных и удалённая разработка;AllowAgentForwarding noзакрывает проброс агента: скомпрометированный сервер иначе может воспользоваться ключом для входа на другие машины. Если переходы между серверами устроены именно так, строку придётся убрать — но предпочтительнее заменить схему наProxyJump, который решает ту же задачу без передачи агента на промежуточный узел;LogLevel VERBOSEдобавляет в журнал отпечатки использованных ключей. Без этого разбор инцидента упирается в записи вида «кто-то вошёл», а Fail2ban и любой последующий анализ работают вслепую;ClientAliveIntervalиClientAliveCountMax: сервер начинает опрос после 300 секунд тишины и разрывает связь после двух неотвеченных проб — то есть примерно через 15 минут в худшем случае, а не через 10, как иногда указывают.
Кто теперь может войти
Раз явного списка нет, вход формально открыт всем учётным записям — но фактически только тем, у кого есть непустой authorized_keys и работающая оболочка. Служебные записи опасности не представляют: у них прописан /usr/sbin/nologin, и вход не состоится даже при наличии ключа. Проверка реальной картины:
getent passwd | awk -F: '$7 !~ /(nologin|false)$/ {print $1, $3, $7}'ls -l /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys 2>/dev/nullПервая команда покажет записи с рабочей оболочкой, вторая — у кого лежат ключи. Пересечение этих двух списков и есть настоящий перечень допущенных. Его следует просматривать после каждого добавления учётной записи и при разборе любого подозрительного события.
Если круг всё же нужно сузить
Бывают требования, при которых доступ должен предоставляться явно, а не выводиться из наличия ключа: регламент, аудит, машина с большим числом учётных записей. Тогда вместо перечисления имён применяется группа — конфигурацию править больше не придётся:
groupadd -f ssh-usersusermod -aG ssh-users rootИ одна строка в 00-hardening.conf:
AllowGroups ssh-usersВыдача доступа сводится к одной команде usermod, а забыть вписать имя в файл уже невозможно. Важно: AllowGroups и PermitRootLogin проверяются независимо, и вход должен быть разрешён обеими директивами. Оставленное по невнимательности PermitRootLogin no рядом с группой, куда включён root, закрывает доступ полностью — при формально безупречном синтаксисе, и sshd -t этого не увидит.
Запрет cloud-init возвращать пароли
Файл 50-cloud-init.conf перегенерируется при перезагрузке машины. Сам по себе он уже не опасен — файл с меньшим номером читается раньше, — но источник расхождения лучше убрать:
nano /etc/cloud/cloud.cfg.d/99-disable-ssh-pwauth.cfgСодержимое файла — одна строка:
ssh_pwauth: falseТолько после этого удаляется файл, созданный cloud-init:
rm -f /etc/ssh/sshd_config.d/50-cloud-init.confУдаление без первого шага бесполезно: cloud-init создаст файл заново при следующей загрузке.
Проверка синтаксиса
Каталог /run/sshd создаётся при старте службы, а при активации сокетом существует только пока обрабатывается соединение. Без него проверка обрывается сообщением Missing privilege separation directory: /run/sshd, поэтому каталог создаётся заранее — команда безвредна, если он уже есть:
mkdir -p /run/sshdsshd -t; echo "код возврата: $?"Нулевой код и отсутствие вывода означают корректный синтаксис. При ненулевом коде дальнейшие шаги не выполняются — сначала правится файл. Это и есть главная страховка: пока проверка не пройдена, конфигурация ни на что не влияет.
Применение
Здесь режим из шага 1 имеет значение.
При активации сокетом делать не нужно ничего. На каждое новое соединение поднимается отдельный процесс sshd, который читает конфигурацию заново, — правки действуют начиная со следующего подключения. Уже открытые сессии не затрагиваются. Команда systemctl reload ssh в этом режиме ответит Unit cannot be reloaded because it is inactive, и это не ошибка.
Классическому демону нужна перезагрузка конфигурации:
systemctl reload sshИменно reload, а не restart: перезагрузка конфигурации не разрывает уже установленные соединения.
Что получилось на самом деле
Итоговые значения после слияния всех файлов показывает только режим тестового вывода — не содержимое файлов:
mkdir -p /run/sshd && sshd -T | grep -Ei "authenticationmethods|permitrootlogin|passwordauthentication|kbdinteractive|pubkeyauthentication|allowusers|allowgroups|maxauthtries|strictmodes"Ожидаемый результат:
permitrootlogin without-password
pubkeyauthentication yes
passwordauthentication no
kbdinteractiveauthentication no
strictmodes yes
maxauthtries 6
authenticationmethods publickeyСтрок allowusers и allowgroups в выводе быть не должно — их отсутствие подтверждает, что ограничения по именам нет и появиться неоткуда. Если хотя бы одна из них всплыла, значит её задаёт другой файл в sshd_config.d/, и это стоит выяснить до того, как список кого-нибудь отрежет.
Деталь, сбивающая с толку: вместо prohibit-password вывод показывает without-password. Это устаревший синоним того же значения, sshd печатает каноническую форму.
Полезно также убедиться, что ключей в файле действительно столько, сколько ожидается — рабочий и резервный:
grep -c '^ssh-' /root/.ssh/authorized_keysКонтрольная проверка: пароль действительно не проходит
Из отдельного окна терминала, не закрывая рабочую сессию, клиент принудительно переводится на парольный вход:
ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no root@<server_ip>Правильный ответ сервера — Permission denied (publickey). Он означает, что парольная аутентификация закрыта и остаётся единственный путь — ключ.
После ближайшей перезагрузки сервера имеет смысл повторить sshd -T: если 50-cloud-init.conf не вернулся, а значения те же, конфигурация устойчива.
Заодно: кто уже заходил на сервер
Пока пароли были разрешены, у переборщиков был шанс — и на облачном образе это окно приходится ровно на root. Проверка журналов:
journalctl -t sshd --since "7 days ago" --no-pager | grep 'Accepted password'last -a | head -20Фильтр по метке -t sshd, а не по единице -u ssh: при активации сокетом записи попадают в журналы отдельных единиц вида ssh@0-...service, и фильтр по ssh.service пропустит почти всё.
Строки вида Accepted password for root from <IP> — это успешные входы по паролю. Если адрес посторонний, настройкой SSH дело не ограничивается: система считается недоверенной и разбирается отдельно — поиск посторонних процессов, заданий планировщика и лишних ключей в authorized_keys.
Шаг 5. Встроенные штрафы OpenSSH (PerSourcePenalties)
Начиная с OpenSSH 9.8 в сервере работает собственный механизм отсечения назойливых адресов — PerSourcePenalties. Он включён по умолчанию, не требует настройки и работает независимо от Fail2ban и межсетевого экрана. Настраивать его в большинстве случаев не нужно, а вот знать о нём необходимо: он порождает симптомы, которые легко принять за сломанную конфигурацию.
Текущие значения:
mkdir -p /run/sshd && sshd -T | grep -i persourceТипичный вывод:
persourcepenaltyexemptlist none
persourcemaxstartups none
persourcenetblocksize 32:128
persourcepenalties crash:90 authfail:5 noauth:1 grace-exceeded:10 refuseconnection:10 max:600 min:15 max-sources4:65536 max-sources6:65536 overflow:permissive overflow6:permissiveРазбор значений:
noauth:1— соединение, закрытое без единой попытки аутентификации, добавляет 1 секунду штрафа. Именно так выглядит любая проверка доступности порта утилитойncили сканером;authfail:5— неудачная проверка ключа или пароля даёт 5 секунд;crash:90— аварийное завершение обработчика соединения, максимально подозрительное событие;min:15— минимальный срок штрафа 15 секунд. То есть даже единственное «лёгкое» нарушение с весом в одну секунду блокирует адрес на четверть минуты;max:600— потолок в 10 минут, до которого штрафы накапливаются при повторных нарушениях;persourcenetblocksize 32:128— штраф начисляется на конкретный адрес (маска /32 для IPv4), а не на подсеть;persourcepenaltyexemptlist none— исключений нет, под правило попадают все адреса без разбора.
Как это выглядит при диагностике
Заштрафованный адрес получает разрыв соединения до обмена версиями протокола. На стороне клиента:
kex_exchange_identification: Connection closed by remote host
Connection closed by <server_ip> port 22На стороне сервера в журнале:
journalctl -t sshd --since "1 hour ago" --no-pager | grep srclimitsshd[957]: srclimit_penalise: ipv4: new 203.0.113.10/32 deferred penalty of 1 seconds for penalty: connections without attempting authenticationОтличить этот механизм от остальных просто по характеру отказа. Штраф OpenSSH принимает TCP-соединение и обрывает его сразу после установки — клиент видит Connection closed. Блокировка в межсетевом экране даёт либо мгновенный отказ (Connection refused), либо тишину до истечения времени ожидания, в зависимости от того, отбрасывается пакет или отвергается. Записей в Fail2ban при этом нет вообще — механизмы никак не связаны.
Когда механизм всё же настраивают
Три случая, в которых значения по умолчанию мешают.
Первый — адрес системы мониторинга или сборочного конвейера, который регулярно проверяет доступность порта. Такой адрес выводится из-под правила:
nano /etc/ssh/sshd_config.d/00-hardening.confВ конец файла добавляется строка со списком адресов через запятую:
PerSourcePenaltyExemptList 203.0.113.10,198.51.100.0/24Второй — сервер за общим адресом источника: несколько клиентов приходят с одного адреса преобразования, и штраф от одного из них отрезает всех. Здесь либо тот же список исключений, либо укрупнение блока — но последнее скорее ухудшит ситуацию, поэтому предпочтителен список.
Третий — отладка, когда механизм мешает воспроизвести проблему. Отключается целиком:
PerSourcePenalties noОтключать на постоянной основе не стоит: механизм дешевле Fail2ban, срабатывает мгновенно и не требует разбора журналов. Проверка применённого значения — всё той же командой sshd -T | grep -i persource.
На OpenSSH младше 9.8 вывод будет пустым — механизма в сборке нет, и весь этот шаг пропускается. Версия проверяется командой sshd -V (в старых сборках — ssh -V).
Шаг 6. Межсетевой экран (UFW)
apt install ufw -yufw default deny incomingufw default allow outgoingufw limit 22/tcpufw --force enableКлюч --force избавляет от вопроса Proceed with operation (y|n)?. Без него, если блок команд вставлен в терминал целиком, следующая строка будет съедена как ответ на этот вопрос.
ufw status verboseПравило limit одновременно открывает порт и включает ограничение частоты подключений: адрес, открывший шесть и более соединений за 30 секунд, временно блокируется. Отдельное allow 22/tcp не нужно.
Три уточнения. Ограничение частоты в UFW работает для IPv4; при наличии публичного IPv6-адреса нагрузку по нему берёт на себя Fail2ban. Если SSH переведён на нестандартный порт, в правиле указывается именно он. И следует помнить про сам порог в шесть соединений за полминуты: во время настройки открывается второе окно, запускается ssh -vvv, проверяется вход по паролю и по ключу — счётчик набегает быстро, а блокировка средствами UFW не оставляет записей в журнале Fail2ban и выглядит просто как «сервер не отвечает».
Что сканер видит на закрытых портах
Политика deny incoming в UFW реализована как отбрасывание пакета, а не как отказ. Разница принципиальна: на пакет к закрытому порту сервер не отвечает вообще ничего. В отчёте сканера такой порт помечен как filtered, а не closed, и на каждый уходит полное время ожидания вместо мгновенного отказа. Быстрое сканирование всего диапазона превращается в многочасовое.
Это и есть основная защита от сканирования, и она уже включена — отдельных настроек не требует. Дальше речь идёт не о невидимости, которой не бывает, а о втором рубеже: отсечь адрес, прощупывающий порты, целиком и до того, как он перейдёт к прицельным действиям.
Журналирование межсетевого экрана
Без записей о заблокированных пакетах отсекать сканирующие адреса нечем. Проверка:
ufw status verbose | grep -i loggingОжидается Logging: on (low) — этот уровень включается вместе с самим межсетевым экраном и логирует именно отброшенные пакеты. Если журналирование выключено:
ufw logging lowЗаписи делает ядро, поэтому искать их следует в журнале ядра, а не в файле:
journalctl -k --since "1 hour ago" --no-pager | grep -c 'UFW BLOCK'Файл /var/log/ufw.log существует только там, где установлен rsyslog. На минимальных образах Debian 12 и Ubuntu 24.04 его может не быть вовсе, а записи при этом на месте — в journald. Это ровно та же ловушка, что и с /var/log/auth.log в шаге 7, и решается она так же: отбором из журнала, а не чтением файла.
Разрастания журнала бояться не нужно: в цепочках UFW перед записью в журнал стоит ограничение в три записи в минуту с запасом в десять. Сканирование тысячи портов оставляет около десятка строк, а не тысячу. У этого есть обратная сторона, и она учитывается в настройках правила Fail2ban ниже.
Метки в журнале различаются, и это важно для дальнейшего отбора: [UFW BLOCK] — пакет к закрытому порту, [UFW LIMIT BLOCK] — пакет, отброшенный ограничением частоты на 22-м порту, [UFW ALLOW] и [UFW AUDIT] появляются только на повышенных уровнях журналирования.
Шаг 7. Fail2ban против перебора паролей
apt updateapt install fail2ban python3-systemd -yПорядок чтения файлов: обратный тому, что у sshd
Прежде чем править конфигурацию, стоит зафиксировать разницу, на которой ошибаются постоянно. Fail2ban читает файлы в таком порядке:
/etc/fail2ban/jail.conf— штатный файл пакета;/etc/fail2ban/jail.d/*.conf— в алфавитном порядке;/etc/fail2ban/jail.local;/etc/fail2ban/jail.d/*.local.
И применяется последнее встреченное значение параметра, а не первое. То есть здесь всё наоборот по сравнению с sshd: jail.local выигрывает у файлов jail.d/*.conf, а не проигрывает им. Отсюда и рекомендация держать свои настройки именно в jail.local.
Проверять результат всё равно нужно командой, а не чтением файлов:
fail2ban-client get sshd ignoreipЧто уже настроено на сервере
На чистом образе каталог jail.d/ почти пуст. На сервере с панелью управления (CloudPanel, ISPmanager, Plesk) или с уже развёрнутыми службами там лежат чужие файлы, и в них попадаются сюрпризы:
grep -rn '' /etc/fail2ban/jail.d/Два момента, которые нужно найти до правок.
Первый — задвоенные правила. Панели управления нередко объявляют раздел [ssh], тогда как штатное имя правила для той же службы — [sshd]. Оба читают один и тот же журнал, каждая неудачная попытка засчитывается дважды, и порог maxretry = 3 срабатывает после полутора реальных попыток. Диагностика в одну строку:
fail2ban-client statusЕсли в перечне присутствуют одновременно ssh и sshd, конфигурация задвоена. Лишнее правило отключается в том файле, где объявлено, значением enabled = false; иногда таких файлов два, и править нужно оба.
Второй — параметр action, заданный напрямую. Об этом отдельно ниже, в разборе banaction.
Основная конфигурация
Свои настройки размещаются в отдельном файле, чтобы обновления пакета их не затирали:
nano /etc/fail2ban/jail.localСодержимое файла:
[DEFAULT]
bantime = 24h
findtime = 10m
maxretry = 3
backend = systemd
banaction = ufw
# Растущий срок блокировки для повторных нарушителей
bantime.increment = true
bantime.factor = 2
bantime.maxtime = 30d
# Адреса, которые никогда не блокируются
ignoreip = 127.0.0.1/8 ::1 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
[sshd]
enabled = true
journalmatch = SYSLOG_IDENTIFIER=sshdО параметрах:
backend = systemdобязателен для Debian 12 и свежих сборок Ubuntu: файла/var/log/auth.logтам может не быть вовсе, журналы живут в journald. Без этой строки служба падает при старте с ошибкой об отсутствующем файле. Пакетpython3-systemdнужен именно для этого режима;journalmatch = SYSLOG_IDENTIFIER=sshd— критично при активации сокетом. Штатное правило отбирает записи по единицеssh.service, а в этом режиме каждое соединение обслуживает отдельная единицаssh@0-...service. Без этой строки Fail2ban запускается, показываетactive (running)и не видит ни одной попытки входа — счётчик остаётся нулевым при любом количестве атак. Отбор по метке журнала работает в обоих режимах;bantime.incrementудваивает срок при каждом повторном нарушении вплоть до 30 суток;ignoreipсодержит частные диапазоны из RFC 1918 целиком. Они выведены из-под блокировок намеренно: адреса оттуда в интернете не маршрутизируются, попасть с них может только служебный трафик самого сервера, соседняя машина внутренней сети провайдера или собственный преобразователь адресов. Цена ошибочной самоблокировки при единственной учётной записи слишком высока, чтобы экономить на этой строке. Свой постоянный внешний адрес, если он есть, дописывается сюда же — как узнать его надёжно, разбирается ниже.
Почему banaction может не сработать
Строка banaction = ufw заставляет Fail2ban класть запреты через UFW, а не мимо него. Но у неё есть условие, о котором обычно не пишут: banaction учитывается только тогда, когда параметр action не задан явно. Стоит любому файлу конфигурации указать action напрямую — и banaction игнорируется целиком, молча.
Строка вида action = iptables встречается в шаблонах панелей управления регулярно. Результат: блокировки складываются в собственные цепочки Fail2ban, а ufw status их не показывает вообще. Защита при этом работает, но найти заблокированный адрес привычным способом невозможно — и разбор инцидента упирается в пустой вывод.
Проверка того, что применилось на самом деле:
fail2ban-client get sshd actionsОжидается ufw. Если вместо этого iptables или iptables-multiport, значит где-то задан action; найти источник:
grep -rn '^action' /etc/fail2ban/jail.conf /etc/fail2ban/jail.d/ /etc/fail2ban/jail.localЛишняя строка убирается из чужого файла либо перекрывается своей в jail.local — он читается позже и выигрывает.
Где искать сами блокировки при каждом из вариантов:
ufw status numbered | grep -i denyiptables -S | grep f2bСвой внешний адрес: почему ifconfig.me ненадёжен
Стандартный способ узнать собственный адрес — обратиться к внешнему сервису:
curl -s https://ifconfig.me; echoОтвет верен только при прямом выходе в интернет. Если на машине настроен прокси-клиент, VPN-туннель или переменная окружения https_proxy, вернётся адрес выходного узла, а не собственный. Записанный в ignoreip, такой адрес выведет из-под блокировок чужую инфраструктуру и не защитит от самоблокировки.
Надёжный источник — сам сетевой интерфейс:
ip -4 addr show scope global | grep inetОба значения сравниваются. Совпадают — адрес белый и получен напрямую. Расходятся — разбираться следует до того, как значение попадёт в конфигурацию. Адрес из диапазона 100.64.0.0/10 на интерфейсе означает преобразование адресов на стороне провайдера: белого адреса у сервера нет, и часть рекомендаций этой статьи к нему неприменима.
С динамическим адресом вносить его в ignoreip смысла нет вовсе, зато возрастает ценность аварийной консоли.
Запуск и проверка
Синтаксис проверяется до запуска — команда собирает конфигурацию целиком и указывает файл и строку при ошибке:
fail2ban-client -d > /dev/null && echo "конфигурация корректна"systemctl enable --now fail2banКлюч --now включает автозапуск и сразу поднимает службу, поэтому отдельная команда restart не нужна.
systemctl status fail2ban --no-pagerfail2ban-client status sshdПервая команда должна показать active (running), вторая — счётчик обнаруженных попыток и перечень заблокированных адресов. Если через несколько минут работы публичного сервера счётчик остаётся нулевым, почти наверняка не сработал отбор записей из журнала, см. journalmatch выше.
Правило против сканирования портов
Правило [sshd] реагирует на неудачные попытки входа, то есть на этап, когда адрес уже нашёл службу. Сканирование происходит раньше и по журналу sshd не видно вообще — пакеты к закрытым портам до sshd не доходят. Отбор идёт по записям ядра о заблокированных пакетах.
Штатного правила отбора для этого в пакете нет, поэтому создаётся своё:
nano /etc/fail2ban/filter.d/ufw-scan.confСодержимое файла:
[Definition]
failregex = ^.*\[UFW BLOCK].* SRC=<HOST> DST=\S+ .* PROTO=TCP SPT=\d+ DPT=\d+
ignoreregex =Затем добавляется раздел в jail.local:
nano /etc/fail2ban/jail.localФайл целиком принимает вид:
[DEFAULT]
bantime = 24h
findtime = 10m
maxretry = 3
backend = systemd
banaction = ufw
# Растущий срок блокировки для повторных нарушителей
bantime.increment = true
bantime.factor = 2
bantime.maxtime = 30d
# Адреса, которые никогда не блокируются
ignoreip = 127.0.0.1/8 ::1 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
[sshd]
enabled = true
journalmatch = SYSLOG_IDENTIFIER=sshd
[ufw-scan]
enabled = true
filter = ufw-scan
backend = systemd
journalmatch = _TRANSPORT=kernel
maxretry = 5
findtime = 10m
bantime = 6hО параметрах и о самом правиле отбора:
journalmatch = _TRANSPORT=kernel— записи делает ядро, а не служба, поэтому отбор идёт по транспорту, а не по метке. Такой отбор работает и там, где rsyslog не установлен и файла/var/log/ufw.logнет;- условие
PROTO=TCPв правиле отбора обязательно. Сеть провайдера непрерывно шумит широковещательным трафиком — DHCP, IGMP, mDNS, объявления соседних машин, — и всё это идёт по UDP и другим протоколам. Без ограничения по TCP первым в блокировку уходит шлюз провайдера, а вслед за ним и связность сервера; - условие
DPT=\d+заодно отсекает записи без номера порта, а метка[UFW BLOCK]не совпадает с[UFW LIMIT BLOCK]. Из-за этого проверки собственного входа, упирающиеся в ограничение частоты, к этому правилу не относятся; maxretry = 5согласован с ограничением журналирования из шага 6: ядро пишет не более трёх записей в минуту с запасом в десять, поэтому даже сканирование всего диапазона портов даёт около десятка строк. Порог выше десяти недостижим в принципе — правило будет работать, показыватьactiveи никого не блокировать;bantime = 6hвместо общих суток. Сканеры почти не возвращаются, а каждая блокировка — это отдельное правило в межсетевом экране: при сроке в сутки их накапливаются сотни, иufw statusперестаёт читаться. Число блокировок смотрится командойfail2ban-client status ufw-scan, и если оно идёт на тысячи, блокировку переносят на сетевой фильтр провайдера или на действие с ipset;bantime.incrementиз раздела[DEFAULT]действует и здесь: адрес, вернувшийся повторно, получает удвоенный срок;banaction = ufwзакрывает адрес целиком, а не отдельный порт. Для сканирования это единственный осмысленный вариант: порт, который прощупывали, заранее неизвестен.
Правило отбора проверяется на реальных записях, а не в уме. Выборка сохраняется в файл, чтобы проверка не зависела от поддержки журнала в fail2ban-regex:
journalctl -k --since "24 hours ago" --no-pager | grep 'UFW BLOCK' | head -50 > /tmp/ufw-sample.logfail2ban-regex /tmp/ufw-sample.log /etc/fail2ban/filter.d/ufw-scan.confВ итоговой сводке интересны две строки: число совпадений и число пропущенных строк. Пропущенные — это как раз широковещательный шум по UDP, и он должен оставаться пропущенным. Если совпадений ноль при непустом файле выборки, ошибка в правиле отбора; если пуст сам файл выборки, не включено журналирование межсетевого экрана.
Применение и проверка:
fail2ban-client -d > /dev/null && echo "конфигурация корректна"systemctl restart fail2ban; until fail2ban-client ping &>/dev/null; do sleep 2; donefail2ban-client status ufw-scanПроверка на живом сканировании выполняется с постороннего адреса — например, через мобильный интернет:
nmap -Pn -p 3000-3050 <server_ip>Через минуту адрес должен появиться в списке заблокированных. Если сканирование запущено со своего же адреса, внесённого в ignoreip, не произойдёт ничего — и это подтверждение работы исключений, а не поломки правила.
Если служба не поднимается
journalctl -u fail2ban -n 50 --no-pagerСтрока Server ready означает штатную работу. Три типичные ошибки:
ModuleNotFoundError: No module named 'systemd'илиFailed to initialize any backend for Jail 'sshd'— не установлен пакетpython3-systemd;Have not found any log file for sshd jail— обратная ситуация:backend = systemdне указан, а файла/var/log/auth.logв системе нет;Unable to create socket— остался мёртвый каталог сокета после аварийного завершения; лечится командойmkdir -p /var/run/fail2banи повторным запуском.
Ещё одно сообщение попадается почти всегда и ни на что не влияет: WARNING 'allowipv6' not defined in 'Definition'. Using default one: 'auto'. Fail2ban сам определяет доступность IPv6. Убрать его из журнала:
nano /etc/fail2ban/fail2ban.d/allowipv6.conf[Definition]
allowipv6 = autosystemctl restart fail2banСнятие блокировки вручную:
fail2ban-client set sshd unbanip <IP>Либо сразу все, если неизвестно, какое правило сработало:
fail2ban-client unban --allВажна граница применимости: после запрета паролей перебор физически не может привести к входу. Fail2ban решает другую задачу — снижает паразитную нагрузку и убирает шум из журналов. Его неработоспособность неприятна, но доступ к серверу и защищённость от подбора пароля от неё не зависят.
Три независимых механизма: как их различать
К этому моменту на сервере работают три ограничителя, никак не связанных между собой. При потере доступа важно понимать, какой из них сработал — снимаются они по-разному.
- Штрафы OpenSSH. Симптом —
kex_exchange_identification: Connection closed by remote host. Соединение установилось и оборвано до обмена версиями. Снимается только ожиданием, максимум 10 минут; в журнале ищется по словуsrclimit; - Ограничение частоты в UFW. Симптом — соединение не устанавливается вовсе, клиент ждёт до истечения времени ожидания. В журнале ядра метка
[UFW LIMIT BLOCK]. Проходит само примерно через полминуты после прекращения попыток; - Блокировка Fail2ban. Симптом зависит от действия:
ufwотбрасывает пакет молча,iptablesс отказом даёт мгновенныйConnection refused. Снимается командойfail2ban-client unban --all, срок по умолчанию — сутки с удвоением при повторах.
Общая диагностика с сервера, когда доступ ещё есть:
journalctl -t sshd --since "30 min ago" --no-pager | grep -E 'srclimit|Failed|Invalid'journalctl -k --since "30 min ago" --no-pager | grep -E 'UFW (LIMIT )?BLOCK' | tail -20fail2ban-client status; ufw status numbered | grep -i denyШаг 8. Автоматические обновления безопасности
apt install unattended-upgrades -ydpkg-reconfigure --priority=low unattended-upgradesПроверка работоспособности механизма:
unattended-upgrades --dry-run --debugТонкая настройка находится в отдельном файле:
nano /etc/apt/apt.conf.d/50unattended-upgradesДва параметра там заслуживают внимания:
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "04:00";Автоматическая перезагрузка нужна потому, что обновления ядра и системных библиотек без неё не вступают в силу. Время выбирается по наименьшей нагрузке. Для нагруженного сервера с непрерывными службами перезагрузку оставляют выключенной, но тогда за появлением файла /var/run/reboot-required приходится следить самостоятельно.
Всё одним блоком
Шаги 3–8 сводятся к одному блоку, который выполняется целиком за одну вставку в терминал. Он записывает скрипт в /root/harden-ssh.sh и сразу запускает его; отдельный файл нужен потому, что set -euo pipefail внутри интерактивной оболочки завершил бы сессию при первой же ошибке.
Скрипт идемпотентен — повторный запуск на уже настроенном сервере ничего не ломает и приводит конфигурацию к тому же состоянию.
Что нужно сделать до запуска
Три вещи автоматизировать невозможно, и без них блок либо откажется работать, либо оставит сервер без проверки:
- Открытый ключ уже лежит в
/root/.ssh/authorized_keys— шаги 2 и 3. Это главный предохранитель: скрипт проверяет файл первым делом и прерывается, если тот пуст. Без этой проверки запрет паролей отрезал бы доступ немедленно; - Открыта аварийная консоль провайдера — шаг 0. Проверяется до запуска, а не после;
- Текущая сессия не закрывается до тех пор, пока вход не проверен из отдельного окна. Скрипт напомнит об этом в конце, но выполнить проверку за администратора не может.
Настройки в начале блока
Пять переменных в шапке скрипта — единственное, что правится под конкретный сервер:
SSH_PORT— порт SSH. При значении, отличном от 22, скрипт сам определит режим запуска службы и внесёт изменение в нужное место: в определение сокета либо в конфигурацию sshd. Порт 22 при этом остаётся открытым в межсетевом экране как страховка: сначала проверяется вход по новому порту, и только потом удаляется старое правило. Порядок тот же, что в разделе о смене порта, — иначе ошибка в конфигурации отрезает доступ мгновенно;EXTRA_PORTS— порты уже работающих служб, которые должны остаться доступными. Формат тот же, что у командыufw allow:"80/tcp 443/tcp 443/udp". Для веб-сервера с HTTP/3 нужны обе записи для 443, для почтового узла — весь набор из шага 6. Пустое значение оставляет открытым только SSH;ADMIN_IPS— список адресов через пробел. Пустое значение оставляет SSH открытым для всех с ограничением частоты подключений; заполненное закрывает порт для всех, кроме перечисленных. Заполнять его при первом запуске рискованно: ошибка в адресе означает поход в аварийную консоль;IGNORE_IP— исключения Fail2ban. По умолчанию частные диапазоны RFC 1918; сюда же дописывается собственный постоянный внешний адрес, если он есть;AUTO_REBOOT— автоматическая перезагрузка при обновлениях ядра.
Перед включением межсетевого экрана скрипт собирает список портов, которые сейчас слушаются на внешних адресах, и сверяет его с разрешёнными. Всё, что не попало ни в SSH_PORT, ни в EXTRA_PORTS, выводится предупреждением вида !! слушаются, но будут закрыты: 3306 8080. Порты, привязанные к 127.0.0.1, в проверку не входят — межсетевой экран их не затрагивает.
Блок
cat > /root/harden-ssh.sh <<'HARDEN'
#!/usr/bin/env bash
set -euo pipefail
#=== настройки ==================================================
SSH_PORT=22
EXTRA_PORTS="" # "80/tcp 443/tcp 443/udp"
ADMIN_IPS="" # "203.0.113.10 198.51.100.0/24"
IGNORE_IP="127.0.0.1/8 ::1 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16"
AUTO_REBOOT=false
#================================================================
die() { echo "ОШИБКА: $*" >&2; exit 1; }
info() { echo "==> $*"; }
warn() { echo "!! $*"; }
[ "$(id -u)" -eq 0 ] || die "требуется запуск от root"
### 0. Предохранитель: ключ должен быть на месте
KF=/root/.ssh/authorized_keys
[ -s "$KF" ] || die "$KF пуст или отсутствует — сначала добавьте открытый ключ (шаг 3)"
N=$(grep -cE '^(ssh-|ecdsa-|sk-)' "$KF" || true)
[ "${N:-0}" -ge 1 ] || die "в $KF нет ни одного корректного ключа"
info "ключей в authorized_keys: $N"
[ "$N" -ge 2 ] || warn "резервного ключа нет — при потере устройства останется только аварийная консоль"
### 1. Права на файлы ключей
chown -R root:root /root/.ssh
chmod 700 /root /root/.ssh
chmod 600 "$KF"
### 2. Режим запуска службы
if systemctl is-active --quiet ssh.socket; then MODE=socket; else MODE=daemon; fi
info "режим запуска SSH: $MODE"
### 3. Конфигурация sshd с наивысшим приоритетом
mkdir -p /etc/ssh/sshd_config.d
cat > /etc/ssh/sshd_config.d/00-hardening.conf <<EOF
AuthenticationMethods publickey
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitEmptyPasswords no
PermitRootLogin prohibit-password
StrictModes yes
MaxAuthTries 6
LoginGraceTime 30
MaxSessions 10
X11Forwarding no
PermitTunnel no
AllowAgentForwarding no
LogLevel VERBOSE
ClientAliveInterval 300
ClientAliveCountMax 2
EOF
chmod 644 /etc/ssh/sshd_config.d/00-hardening.conf
[ "$MODE" = daemon ] && [ "$SSH_PORT" != 22 ] && \
echo "Port $SSH_PORT" >> /etc/ssh/sshd_config.d/00-hardening.conf
### 4. Запрет cloud-init возвращать пароли
if [ -d /etc/cloud/cloud.cfg.d ]; then
echo 'ssh_pwauth: false' > /etc/cloud/cloud.cfg.d/99-disable-ssh-pwauth.cfg
rm -f /etc/ssh/sshd_config.d/50-cloud-init.conf
fi
### 5. Проверка синтаксиса — до применения
mkdir -p /run/sshd
sshd -t || die "конфигурация sshd некорректна, ничего не применено"
### 6. Применение
if [ "$MODE" = socket ]; then
if [ "$SSH_PORT" != 22 ]; then
mkdir -p /etc/systemd/system/ssh.socket.d
printf '[Socket]\nListenStream=\nListenStream=%s\n' "$SSH_PORT" \
> /etc/systemd/system/ssh.socket.d/override.conf
systemctl daemon-reload
systemctl restart ssh.socket
fi
else
if [ "$SSH_PORT" != 22 ]; then systemctl restart ssh; else systemctl reload ssh; fi
fi
### 7. Пакеты
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq ufw fail2ban python3-systemd unattended-upgrades
### 8. Межсетевой экран (существующие правила не сбрасываются)
ufw default deny incoming
ufw default allow outgoing
if [ -n "$ADMIN_IPS" ]; then
for ip in $ADMIN_IPS; do
ufw allow from "$ip" to any port "$SSH_PORT" proto tcp
done
else
ufw limit "$SSH_PORT"/tcp
fi
for p in $EXTRA_PORTS; do ufw allow "$p"; done
### 8a. Страховка на время смены порта: старый 22 остаётся открытым
KEEP22=0
if [ "$SSH_PORT" != 22 ]; then
ufw limit 22/tcp
KEEP22=1
fi
### 8b. Сверка: что слушается, но останется закрытым
ALLOWED="$SSH_PORT"
[ "$KEEP22" = 1 ] && ALLOWED="$ALLOWED 22"
for p in $EXTRA_PORTS; do ALLOWED="$ALLOWED ${p%%/*}"; done
LISTEN=$(ss -tlnH 2>/dev/null | awk '$4 !~ /^(127\.|\[::1\])/ {sub(/.*:/,"",$4); print $4}' | sort -un)
CLOSED=""
for p in $LISTEN; do
case " $ALLOWED " in *" $p "*) ;; *) CLOSED="$CLOSED $p" ;; esac
done
if [ -n "$CLOSED" ]; then
warn "слушаются, но будут закрыты:$CLOSED"
warn "если это рабочие службы — прервите (Ctrl+C), впишите порты в EXTRA_PORTS и запустите заново"
sleep 10
fi
ufw --force enable
ufw logging low
### 9. Правило отбора для сканирования портов
cat > /etc/fail2ban/filter.d/ufw-scan.conf <<'EOF'
[Definition]
failregex = ^.*\[UFW BLOCK].* SRC=<HOST> DST=\S+ .* PROTO=TCP SPT=\d+ DPT=\d+
ignoreregex =
EOF
### 10. Конфигурация Fail2ban
cat > /etc/fail2ban/jail.local <<EOF
[DEFAULT]
bantime = 24h
findtime = 10m
maxretry = 3
backend = systemd
banaction = ufw
# возвращает действие по умолчанию, если панель управления задала action напрямую
action = %(action_)s
bantime.increment = true
bantime.factor = 2
bantime.maxtime = 30d
ignoreip = $IGNORE_IP
[sshd]
enabled = true
journalmatch = SYSLOG_IDENTIFIER=sshd
port = $SSH_PORT
[ufw-scan]
enabled = true
filter = ufw-scan
backend = systemd
journalmatch = _TRANSPORT=kernel
maxretry = 5
findtime = 10m
bantime = 6h
EOF
### 11. Отключение задвоенных правил от панелей управления
for f in /etc/fail2ban/jail.d/*.conf; do
[ -e "$f" ] || continue
if grep -qE '^\[ssh\]' "$f"; then
sed -i 's/^enabled *= *true/enabled = false/' "$f"
warn "отключено задвоенное правило [ssh] в $f"
fi
done
### 12. Запуск Fail2ban с ожиданием готовности
fail2ban-client -d >/dev/null || die "конфигурация fail2ban некорректна"
systemctl enable --now fail2ban >/dev/null 2>&1 || true
systemctl restart fail2ban
until fail2ban-client ping &>/dev/null; do sleep 2; done
### 13. Автоматические обновления безопасности
cat > /etc/apt/apt.conf.d/20auto-upgrades <<'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
EOF
if [ "$AUTO_REBOOT" = true ]; then
cat > /etc/apt/apt.conf.d/51unattended-reboot <<'EOF'
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "04:00";
EOF
fi
### 14. Итоговая проверка
echo; info "--- применённая конфигурация sshd ---"
sshd -T | grep -Ei 'authenticationmethods|permitrootlogin|passwordauthentication|kbdinteractive|maxauthtries|allowusers|allowgroups'
echo; info "--- встроенные штрафы OpenSSH ---"
sshd -T | grep -i persourcepenalties || echo "механизм отсутствует (OpenSSH младше 9.8)"
echo; info "--- слушающий порт ---"
ss -tlnp | grep -E ":${SSH_PORT}\b" || warn "порт $SSH_PORT не слушается"
echo; info "--- межсетевой экран ---"
ufw status verbose | head -12
echo; info "--- Fail2ban ---"
fail2ban-client status
echo -n "действие блокировки: "; fail2ban-client get sshd actions
fail2ban-client get sshd ignoreip
echo
info "Готово. НЕ закрывайте текущую сессию."
info "Проверьте вход из отдельного окна: ssh -p $SSH_PORT root@<server_ip>"
if [ "$KEEP22" = 1 ]; then
warn "порт 22 оставлен открытым как страховка на время смены порта"
warn "после успешной проверки входа по $SSH_PORT удалите правило: ufw delete limit 22/tcp"
fi
HARDEN
chmod +x /root/harden-ssh.sh && /root/harden-ssh.shЧто проверить в выводе
Скрипт завершается сводкой, и в ней важны пять строк:
authenticationmethods publickeyиpasswordauthentication no— вход по паролю закрыт;allowusersиallowgroupsотсутствуют — ограничения по именам нет и появиться неоткуда;- строка со слушающим портом непустая;
- перечень правил Fail2ban содержит
sshdиufw-scan, но не содержитssh— задвоения нет; действие блокировки: ufw— а неiptables, иначе блокировки уйдут мимо межсетевого экрана.
Строки, начинающиеся с !!, — предупреждения. Они не прерывают работу, но требуют внимания: отсутствие резервного ключа, отключённое задвоенное правило, не поднявшийся порт, закрываемые службы.
И только после этого — проверка входа из отдельного окна терминала. Текущая сессия закрывается последней. Если порт менялся, последним действием удаляется временное правило:
ufw delete limit 22/tcpОпционально: SSH только с известных адресов
Это единственная мера, которая действительно убирает SSH из зоны видимости сканера: со всех адресов, кроме разрешённых, порт выглядит закрытым, а журналы перестают наполняться попытками входа полностью. Условие — постоянный адрес у администратора и работающая аварийная консоль, потому что цена ошибки здесь максимальная из всей статьи.
Свой адрес определяется как описано выше — сравнением ip -4 addr show и внешнего сервиса, а не одним лишь curl.
Новое правило добавляется до удаления прежнего:
ufw allow from <IP> to any port 22 proto tcpНесколько адресов — несколько таких правил; при известной подсети вместо адреса указывается она, например 203.0.113.0/24. Если SSH перенесён на другой порт, в правиле указывается он.
Затем подключение проверяется из отдельного окна терминала, и только после этого снимается общее правило:
ufw delete limit 22/tcpufw status numberedТри следствия. Ограничение частоты подключений и правило [sshd] в Fail2ban после этого почти простаивают — перебор снаружи невозможен физически; удалять их не нужно, они остаются на случай возврата к общему доступу. У машины с публичным IPv6-адресом доступ по нему пропадёт вместе с правилом limit 22/tcp: политика по умолчанию отбросит соединение, и для IPv6 при необходимости добавляется отдельное правило. И правило против сканирования из шага 7 продолжает работать в полную силу — закрытых портов у сервера стало больше, а не меньше.
При динамическом адресе правило придётся править после каждой смены, и это быстро становится невыносимым. «Стук по портам» (knockd) и однопакетная авторизация (fwknop) решают ту же задачу без постоянного адреса, но добавляют службу, от работоспособности которой зависит доступ к серверу, и ломают всё, что подключается без человека: сборочные конвейеры, git по SSH, средства управления конфигурацией. Практичнее вынести SSH за VPN и разрешить 22-й порт только с адреса внутри туннеля — механика та же, зато отказ туннеля не превращается в потерю сервера.
Опционально: смена порта SSH
Перенос SSH с 22-го порта не добавляет стойкости — целенаправленное сканирование найдёт службу за минуты. Но он на порядок сокращает объём фонового шума в журналах.
Способ зависит от режима из шага 1, и это единственное место, где разница принципиальна.
При активации сокетом
Директива Port в конфигурации sshd в этом режиме игнорируется — порт задан в определении сокета. Меняется он там:
systemctl edit ssh.socketВ открывшемся редакторе добавляется:
[Socket]
ListenStream=
ListenStream=2222Пустое присваивание в первой строке обязательно: оно сбрасывает значение по умолчанию. Без него сокет будет слушать оба порта.
systemctl daemon-reloadsystemctl restart ssh.socketПри классическом демоне
Порт дописывается в файл с наивысшим приоритетом:
nano /etc/ssh/sshd_config.d/00-hardening.confВ конец файла добавляется одна строка:
Port 2222Если строка Port в файле уже есть, её нужно изменить, а не добавить вторую: при двух директивах служба будет слушать оба порта.
mkdir -p /run/sshd && sshd -t && systemctl restart sshЗдесь нужен именно restart: смена порта требует пересоздания слушающего сокета, перезагрузка конфигурации его не пересоздаёт. Открытая сессия не разрывается.
Дальше одинаково для обоих режимов
Порт открывается в межсетевом экране:
ufw limit 2222/tcpИ указывается в правиле Fail2ban:
nano /etc/fail2ban/jail.localРаздел для sshd принимает вид:
[sshd]
enabled = true
journalmatch = SYSLOG_IDENTIFIER=sshd
port = 2222systemctl restart fail2banПроверка, что порт слушается:
ss -tlnp | grep -E ':2222\b'Старое правило межсетевого экрана убирается только после того, как подключение по новому порту проверено из второго терминала:
ufw delete limit 22/tcpИтоговая конфигурация
- единственный допустимый способ входа зафиксирован директивой
AuthenticationMethods publickey— на уровне протокола, независимо от настроек PAM; - парольная аутентификация отключена дополнительно двумя директивами, включая интерактивный запрос через PAM;
- вход под root возможен только по ключу — отдельным рубежом, независимым от общего параметра;
- круг допущенных определяется наличием ключа, а не списком имён в конфигурации: добавление администратора не требует правки файлов и не может закончиться забытым именем;
- в
authorized_keysлежат рабочий и резервный ключи ed25519, чужих строк нет; - права на
/root,/root/.sshиauthorized_keysсогласованы сStrictModes; - проброс агента и туннели tun/tap отключены, проброс портов сохранён работоспособным;
- все критичные параметры собраны в одном файле
00-hardening.confс наивысшим приоритетом; - cloud-init не может вернуть пароли обратно после перезагрузки;
- встроенный механизм штрафов OpenSSH проверен и учтён при диагностике, при необходимости для него задан список исключений;
- межсетевой экран включён, лишние порты закрыты, для SSH работает ограничение частоты подключений;
- политика по умолчанию отбрасывает пакеты молча: закрытые порты видны сканеру как
filtered, каждый обходится ему полным временем ожидания; - журналирование межсетевого экрана включено, записи ядра о заблокированных пакетах доступны в journald независимо от наличия rsyslog;
- адреса, прощупывающие закрытые порты, блокируются отдельным правилом Fail2ban, без ложных срабатываний на широковещательный шум сети провайдера и на ограничение частоты подключений;
- Fail2ban видит записи журнала в любом режиме запуска службы, правила не задвоены, действие блокировки проверено командой, а не предположением;
- частные диапазоны выведены из-под блокировок, самоблокировка исключена;
- обновления безопасности устанавливаются автоматически;
- способ запуска службы SSH остался тем, который настроил провайдер образа.
Такой набор закрывает базовые риски для любого публичного сервера. Следующие уровни — двухфакторная аутентификация, ключи на аппаратных токенах (ed25519-sk), вынос SSH за VPN и централизованный сбор журналов — надстраиваются поверх него. Отдельным пунктом стоит переход к персональным учётным записям с sudo: он становится обязательным в момент, когда доступ к серверу получает второй человек.
Частые ошибки
- Переключение способа запуска службы SSH. Комбинация
systemctl disable --now ssh.socketиsystemctl enable --now sshкочует по руководствам, но на части образов сокет прописан в зависимостях службы. После остановки сокета systemd считает службу неактивной, а реальный процесс sshd продолжает держать порт — и любая попытка запуска отвечаетAddress already in use. Выигрыш от переключения косметический, риск реальный; - Проверка доступности SSH утилитой
nc. Соединение без попытки аутентификации — ровно то поведение, которое наказываетPerSourcePenalties. Несколько проверок подряд отрезают собственный адрес, и следующая попытка входа выглядит как отказ сервера. Доступность проверяется командойssh, состояние порта — командойss -tlnpна самом сервере; - Диагноз «сломалась конфигурация» по сообщению
kex_exchange_identification: Connection closed by remote host. Это подпись штрафа OpenSSH, а не ошибка в файлах. Правки конфигурации в такой ситуации ничего не меняют, помогает только ожидание; - Уверенность, что
banaction = ufwобязательно применился. Параметр игнорируется молча, если где-либо заданactionнапрямую. Блокировки в этом случае лежат в цепочках iptables, аufw statusпоказывает пустоту. Проверка —fail2ban-client get sshd actions; - Задвоенные правила Fail2ban. Разделы
[ssh]и[sshd]одновременно читают один журнал, каждая попытка считается дважды, порог срабатывает вдвое быстрее. Диагностика —fail2ban-client status; - Перенос правила приоритетов sshd на Fail2ban. У sshd применяется первое встреченное значение и подключаемые файлы читаются раньше основного; у Fail2ban — последнее, и
jail.localчитается после каталогаjail.d/. Логика противоположная; - Определение своего внешнего адреса через
curl ifconfig.meна машине с прокси или туннелем. Вернётся адрес выходного узла, и вignoreipпопадёт чужая инфраструктура; - заготовка вида
<username>или<IP>, оставшаяся в конфигурации дословно:sshd -tошибки не видит,sshd -Tпечатает её как есть, и вывод выглядит правдоподобно; - забытая учётная запись с рабочей оболочкой и старым
authorized_keys: без списка имён в конфигурации именно этот файл и есть перечень допущенных; - запрет паролей только директивой
PasswordAuthentication no: безKbdInteractiveAuthentication noпароль по-прежнему проходит через PAM; - правка только основного
sshd_configбез учёта каталогаsshd_config.d/— файл cloud-init молча перекрывает изменения; - уверенность, что «файл с бо́льшим номером побеждает» — sshd применяет первое встреченное значение, приоритет у меньших номеров;
- непроверенные права на домашний каталог: при
StrictModes yesдоступ на запись группе или всем отключает вход по ключу без внятных сообщений; - ключ добавлен не в тот домашний каталог, потому что команды с
~выполнялись в сессии другой учётной записи; - единственный ключ в
authorized_keysбез резервного — потеря устройства означает поход в аварийную консоль; MaxAuthTries 3при нескольких ключах в агенте — до нужного ключа дело не доходит, сервер отвечаетToo many authentication failures;- ожидание, что
ssh-copy-idсработает на облачном образе Ubuntu: пароли там запрещены ещё до всех правок; AuthenticationMethods publickeyвместе с планами на двухфакторную аутентификацию — она перестанет работать, требуется значениеpublickey,keyboard-interactive;- Fail2ban без
journalmatchпри активации сокетом: служба работает, счётчик попыток вечно нулевой; - правило отбора записей UFW без условия
PROTO=TCP: широковещательный шум сети провайдера идёт по UDP, и первым в блокировку уходит шлюз; - отбор записей межсетевого экрана из
/var/log/ufw.logна образе без rsyslog — файла нет, правило формально работает, счётчик вечно нулевой; записи ядра лежат в journald; maxretryвыше десяти для правила против сканирования: ядро пишет не более трёх записей в минуту, и такой порог недостижим при любом количестве атак;- суточный срок блокировки для сканеров — сотни правил в межсетевом экране за неделю при нулевой пользе, поскольку повторно они почти не приходят;
- проверка правила против сканирования со своего же адреса, внесённого в
ignoreip; - удаление правила
limit 22/tcpдо того, как проверен вход по новому правилу со списком разрешённых адресов; - «стук по портам» на машине, к которой подключаются сборочные конвейеры и git, — доступ без человека перестаёт работать первым;
- поиск попыток входа через
journalctl -u sshпри активации сокетом — записи лежат в единицахssh@N.service, нужен фильтр-t sshd; - проверка результата чтением файлов вместо
sshd -T— только эта команда показывает реально применённые значения; - ожидание, что директива
Portсработает при активации сокетом; - контрольная проверка парольного входа после настройки ограничений — несколько намеренных отказов расходуются сразу по трём счётчикам;
- включение UFW до добавления правила для SSH, а также
ufw enableбез--forceв блоке команд; - вставка запуска Fail2ban и проверки его состояния одним блоком команд — клиент обращается к сокету раньше, чем сервер его создал;
- отсутствие аварийной консоли провайдера в закладках и незнание пароля от неё.
Частые вопросы
Почему вход по ключу не проходит при корректном authorized_keys?
Наиболее частая причина — права доступа. При StrictModes yes sshd отказывается читать authorized_keys, если домашний каталог, каталог .ssh или сам файл доступны на запись группе или всем. Ожидаемые режимы: 700 для /root и /root/.ssh, 600 для файла ключей. Диагностика — ssh -vvv на стороне клиента и journalctl -t sshd на сервере.
Что означает kex_exchange_identification: Connection closed by remote host?
Соединение установилось и было оборвано сервером до обмена версиями протокола. В подавляющем большинстве случаев это встроенный механизм PerSourcePenalties: адрес накопил штраф за соединения без попытки аутентификации либо за неудачные попытки входа. Правки конфигурации не помогут, срок истекает сам — максимум за 10 минут при значениях по умолчанию. Подтверждается на сервере командой journalctl -t sshd | grep srclimit.
Как узнать, какие настройки sshd применились на самом деле?
Только командой sshd -T. Чтение файлов результата не даёт: конфигурация собирается из sshd_config и всех файлов каталога sshd_config.d/, причём для каждого параметра применяется первое встреченное значение, а подключаемые файлы читаются раньше основного.
А для Fail2ban порядок такой же?
Нет, противоположный. Fail2ban читает jail.conf, затем jail.d/*.conf, затем jail.local, затем jail.d/*.local, и применяет последнее встреченное значение. Поэтому jail.local перекрывает файлы из каталога, а не наоборот. Проверка применённых значений — командами вида fail2ban-client get sshd ignoreip.
Почему Fail2ban показывает active (running), но не видит ни одной попытки входа?
При активации сокетом каждое соединение обслуживает отдельная единица вида ssh@0-...service, и штатный отбор записей по ssh.service не срабатывает. Решение — строка journalmatch = SYSLOG_IDENTIFIER=sshd в разделе [sshd] файла jail.local. Она корректна в обоих режимах запуска.
Fail2ban блокирует адреса, но ufw status ничего не показывает. Где искать?
Параметр banaction учитывается только при отсутствии явного action. Если где-либо в конфигурации задано action = iptables, блокировки уходят в собственные цепочки Fail2ban мимо UFW. Проверка — fail2ban-client get sshd actions, поиск источника — grep -rn '^action' /etc/fail2ban/, сами правила — iptables -S | grep f2b.
Правило ufw-scan показывает active, но счётчик нулевой. Почему?
Три причины по частоте. Выключено журналирование межсетевого экрана — проверяется командой ufw status verbose, ожидается Logging: on (low). Отбор настроен на файл /var/log/ufw.log, которого на образе без rsyslog не существует, — нужен backend = systemd и journalmatch = _TRANSPORT=kernel. Либо порог maxretry задан выше десяти: журналирование в ядре ограничено тремя записями в минуту, и такой порог не набирается никогда.
Нужен ли Fail2ban, если в OpenSSH уже есть встроенные штрафы?
Задачи разные. Штрафы OpenSSH работают на коротких сроках (до 10 минут), только для SSH и только по событиям самого sshd. Fail2ban блокирует на сутки с удвоением при повторах, охватывает любые службы с журналами и умеет реагировать на записи ядра о сканировании портов. Механизмы дополняют друг друга, отключать встроенный ради Fail2ban смысла нет.
Защищает ли блокировка сканирующих адресов от чего-нибудь на самом деле?
От самого сканирования — нет, его ведут тысячи разных адресов. Практическая польза двойная: адрес, прощупавший порты перед прицельной атакой, отсекается до неё, а журналы очищаются от постоянного фона. Настоящая защита от сканирования — политика по умолчанию, отбрасывающая пакеты молча, и список разрешённых адресов там, где он применим.
Стоит ли настраивать «стук по портам»?
При постоянном адресе выигрыша над списком разрешённых адресов в межсетевом экране нет, а зависимостей и способов потерять доступ становится больше. При динамическом адресе задача решается выносом SSH за VPN: доступ к порту разрешается только с адреса внутри туннеля, а отказ туннеля не отрезает сервер от аварийной консоли.
ed25519 или RSA?
Для новых конфигураций применяется ed25519: короче ключ, быстрее проверка подписи, устойчивость к ошибкам реализации. RSA оправдан только при длине от 4096 бит и только при работе с оборудованием или ПО, не поддерживающим ed25519.
Нужно ли менять стандартный порт SSH?
На стойкость это не влияет — массовое сканирование обнаруживает службу на любом порту. Практическая польза одна: объём фонового шума в журналах сокращается на порядок, что упрощает разбор реальных инцидентов. Ставить смену порта вместо запрета паролей нельзя, только в дополнение.
Что делать при полной потере доступа по SSH?
Единственный обратный путь — аварийная консоль провайдера (VNC/KVM). Через неё удаляется или правится проблемный файл в /etc/ssh/sshd_config.d/, снимается блокировка адреса командой fail2ban-client unban --all, при необходимости отключается UFW. Перед этим стоит выждать 10 минут: если сработал штраф OpenSSH, доступ вернётся сам и консоль не понадобится. Именно поэтому доступ к консоли проверяется до начала настройки.
Заключение
Настройка занимает меньше двадцати минут, но радикально снижает риск компрометации сервера. Определяющими здесь оказываются не команды, а пять пониманий: sshd применяет первое встреченное значение параметра, подключаемые файлы читаются раньше основного, и собственный 00-hardening.conf снимает вопрос приоритетов раз и навсегда; у Fail2ban порядок обратный, и переносить на него привычки sshd нельзя; способ запуска службы менять не требуется, достаточно знать текущий, поскольку от него зависят три команды и ни одна не относится к защите; доступом управляет ключ, а не список имён в конфигурации; и на сервере одновременно работают три независимых ограничителя — встроенные штрафы OpenSSH, ограничение частоты в межсетевом экране и Fail2ban, — которые дают разные симптомы и снимаются по-разному. Каждое утверждение о конфигурации проверяется командой, а не чтением файла: sshd -T для параметров, ss -tlnp для слушающего порта, fail2ban-client get для правил блокировки, отдельное окно терминала для самого входа. Вход по ключу, закрытый парольный доступ, Fail2ban и межсетевой экран составляют тот минимум, ниже которого публичный Linux-сервер опускаться не должен.