וואלהבכירים במפלגתו של קנצלר גרמניה הביעו בו תמיכה על רקע הלחץ להתפטרESPNTransfer rumors, news: Barça, Madrid believe Haaland could leave Man City next summerThe Jerusalem PostIDF arrests Iran's Press TV journalist in West Bank, hands her to police, source confirms to 'Post'UN NewsThe world comes to New York: What’s at stake at UN General Assembly high-level weekRTP DesportoOpen de Portugal promovido à primeira divisão do golfe europeuBollywood HungamaAmit Sadh’s Pratap to release in theatres on November 20, 2026; FIRST look unveiledPunchPublic appointments should be based on competence – PRP chieftainInquirerEjercito vows to ensure funding for major railway projectsDigital SpyEmmerdale's Joe Tate to face new revenge plan after Ruby burialVilaWebEl Partit Demòcrata Europeu recorda a Llarena que ha d’aplicar la llei europea i amnistiar PuigdemontBBC NewsCarney's new love-in with EU has everything to do with TrumpGolem.deElon Musk fordert: Unternehmen sollen ihre KI-Systeme gegenseitig prüfen
The Daily Newsstand · Free, Always
Wednesday, September 16, 2026

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

Translate

Устал смотреть в логи: опять подбор пароля к 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, тоже объясняют на их языке: тишина.

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

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.