Сделал себе вышибалу для VPS: кто стучится в.env и в ssh — тот сидит за баном…

в 9:09, , рубрики: Caddy, fail2ban, intrusion-prevention, linux, nftables, nginx, security, ssh

Устал смотреть в логи: опять подбор пароля к ssh, опять лезут в несуществующие файлы сайта. Fail2ban ставил — утонул в настройках. Написал своё: обходит свой сайт, запоминает нормальные адреса, по лишним 404 и по ssh режет IP в nftables. Рассказываю — зачем и как ставил. Код покажу там, где самому было интересно.

Эта история произошла давным‑давно, а может быть только вчера, в одном из миров, чем‑то не похожем на не наш.

Я сидел и тупо смотрел на появляющиеся строчки про подбор паролей к ssh и поиск доступа к файлам сайта.

С чего всё началось

Живёт у меня маленький сервер. Caddy, пару сайтов, ssh — ну всё, как у всех. Логи крутятся сами. Открываешь access‑журнал — там одно и то же: wp‑admin,.env, phpmyadmin, какие‑то config.php.bak. Сайт статический, С WordPress я стараюсь не связываться, нервы дороже.... Это не пользователи ошиблись — это гости без приглашения, перебирают отмычки к чужим дверям.

Параллельно в systemd‑journal сыпется ssh: Invalid user admin, Invalid user oracle… Понятно, что делают. Непонятно, сколько ещё на это смотреть:).

Fail2ban — не зло, но у меня закончилось терпение

Fail2ban людям помогает. Я не кричу «выбросьте его». У меня просто каждый раз одна и та же история: jail, filter, action, перезапуск, «почему не сработало». Для ssh ещё куда ни шло. А вот веб — беда.

Хочешь резать сканеры по 404 — либо банишь всех подряд (один человек опечатался — и привет), либо пишешь простыню регулярок под чужие словари. Словарь у них обновляется быстрее, чем я допиваю чай. Это не работа программиста, это сбор марок чужих дыр.

Плюс на машине уже и так Caddy, логи, firewall — тащить ещё python‑стек ради «может, когда‑нибудь забанит» мне не хотелось. Старая привычка с советских времён — одна программа. Ну и в современных реалиях — однин конфиг и чтобы реально перестал пускать, а не только строка в журнале «забанил бы, будь я птичкой».

Так и родился pet‑проект. Сначала звали CaddyBan, сейчас — Bouncer. Суть та же: вышибала у входа.

Главная мысль: не угадывать, а один раз обойти свой сайт

Сканер не знает, что у тебя на сайте есть на самом деле. Он тыкает в список из интернета. Значит, логично — самому сходить по своим страницам — по ссылкам из HTML, с ограничением глубины и числа страниц, чтобы не положить себя же — и сложить нормальные пути в память, правда ведь? Плюс в конфиг руками дописываешь то, что обход не увидит: API, одностраничники, robots.txt, что там у тебя.

Дальше всё просто, как на заводе:

  • в access‑логе пришла строка: такой‑то IP, код 404, такой‑то путь;

  • путь есть в нашем списке — бывает, битая ссылка на свою же страницу, не трогаем;

  • пути нет — засчитываем промах;

  • набрал, скажем, пять таких промахов за пару минут — не «ой, один раз», а уже похоже на перебор — IP в набор nftables, на час (или как настроишь). Весь вход с этого адреса — стоп. И ssh, и http.

Почему не с первого 404? Потому что живые люди тоже ошибаются. Пять чужих «страниц» подряд — это уже другой разговор.

Кусок обхода — обычная очередь, без наук:

while let Some((path, depth)) = queue.pop_front() {
    if discovered.len() >= config.max_pages {
        break;
    }
    if depth > config.max_depth {
        continue;
    }
    // скачали html, вытащили href, только свой домен
    for capture in href_re.captures_iter(&body) {
        if let Some(path) = resolve_href(&base, &host, href) {
            if discovered.insert(path.clone()) {
                queue.push_back((path, depth + 1));
            }
        }
    }
}

Отдельно — «ловушки»: кто полез в /.env или в wp‑admin на сайте без WordPress — бан сразу, без ожидания пятёрки. Тут уж не опечатка.

Счётчик «сколько раз за окно» — на коленке, но честно

На каждый IP — список времён последних срабатываний. Пришло новое — добавили. Старые, что выпали из окна по времени, с начала списка скидываем. Длина списка — и есть «сколько раз».

while hits.front().is_some_and(|t| now.duration_since(*t) > window) {
    hits.pop_front();
}
hits.len() as u32

Никакого Redis. Один процесс, несколько сайтов — каждому свой журнал и свой каталог путей.

Важная мелочь, на которой можно обжечься: программа не перечитывает старый лог с начала файла. Берёт только новые строки, с момента запуска. Иначе включишь в субботу — и она честно «накажет» пол‑интернета по вчерашнему архиву. Так делать не надо, я специально так не стал делать.

Ssh — там sshd сам подсказал, как не считать дважды

На одну попытку с несуществующим логином sshd пишет две строки. Сначала «нет такого пользователя», потом «не подошёл пароль для несуществующего пользователя». Если обе считать — либо двойной бан, либо опять пляски с фильтрами.

Я решил так: несуществующий пользователь — бан сразу. На проде случайно не набирают admin и oracle — это перебор. А вот к root могут стучаться глупо и настойчиво — там уже порог за окно, как с 404.

Смешной момент: вторая строка от sshd сама не попадает под мой шаблон «неудачный пароль». Между словом «for» и «from» там лишние слова invalid user …. Регулярка ждёт сразу «for что‑то from», а получает «for invalid user admin from». Не совпало — и ладно, первую строку мы уже обработали. OpenSSH меня выручил, отдельный if не понадобился.

static SSH_INVALID_USER: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"Invalid user S+ from (S+) port d+").expect("...")
});

static SSH_FAILED_PASSWORD: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"Failed password for S+ from (S+) port d+").expect("...")
});

Firewall — где я один раз наступил на грабли

Программа только добавляет IP в набор nftables. Рубить трафик должно правило в цепочке input: «если адрес в этом наборе — drop». И это правило должно стоять раньше, чем «принять 443 и 22». Иначе в журнале красиво «забанил», а гость спокойно дальше качает config.php.bak. Установочный скрипт пытается вставить drop в нормальное место; если firewall правил руками — глянь порядок правил, не верь на слово.

Как ставил у себя

Linux, Caddy, nftables уже были. Сначала без настоящего бана — пробный режим, только пишет в лог «вот бы я забанил».

curl -fsSL https://raw.githubusercontent.com/Normal66/Bouncer/main/install.sh | sudo bash -s -- --dry-run

Скрипт скачивает бинарник, ищет access‑логи, пишет конфиг в /etc/bouncer/, ssh в journal подключает, твой IP из сессии в белый список кладёт, systemd‑юнит ставит.

Погонял руками, смотрел вывод:

sudo RUST_LOG=bouncer=info bouncer --config /etc/bouncer/config.toml run

Увидел знакомых сканеров в «вот бы забанил» — успокоился. В конфиге выключил пробный режим, перезапустил сервис.

У Caddy на каждый сайт — json access‑лог в файл, путь совпадает с конфигом. Для одностраничника дописал extra_paths, чтобы честный клиент не попал под 404 «маршрута нет в каталоге».

Проверка:

sudo nft list set inet filter blocked_ips
sudo journalctl -u bouncer | grep banned

Если скрипт сайты сам не нашёл — URL и путь к логу руками:

curl -fsSL .../install.sh | sudo bash -s -- 
  --site https://example.com --log /var/log/caddy/example.com.access.log

Старый CaddyBan у меня был — остановил, конфиг почти один в один перенёс, поставил новый бинарник.

Чего не ждать

Это не замена WAF и не облако. Маршруты, которые появляются только после POST или только в javascript, без ручных дописок в конфиг обход не узнает. С десяти тысяч IP одновременно один набор на сервере не справится — но против одного настойчивого сканера и дешёвого перебора пароля на VPS мне хватило: меньше мусора в логах, меньше ночных «тупо смотреть на строки».

Это не замена WAF и не облако. Маршруты, которые появляются только после POST или только в javascript, без ручных дописок в конфиг обход не узнает. С десяти тысяч IP одновременно один набор на сервере не справится — но против одного настойчивого сканера и дешёвого перебора пароля на VPS мне хватило: меньше мусора в логах, меньше ночных «тупо смотреть на строки».

Если душа просит покопать — установочный скрипт, примеры nftables, почта, changelog — всё лежит на https://github.com/Normal66/Bouncer. README для входа, дальше в репозитории интереснее, чем кажется с первого взгляда; я сам туда залез «на пять минут» и вылез не сразу:).

Я перестал только смотреть в логи. Часть гостей теперь до Caddy не доходит — их встречает firewall. Остальным, кто пять раз подряд просит несуществующий config.php.bak, тоже объясняют на их языке: тишина.

Вот такая история произошла давным‑давно, а может быть только вчера, в одном из миров, чем‑то не похожем на не наш.

Автор: Normal66

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js