Илья Овсенев
← Все статьи

Содержание

  1. Исходные данные
  2. 1. Что такое reverse proxy и зачем он
  3. 2. Подготовка до установки
  4. 3. Установка
  5. 4. Caddyfile
  6. 5. Применение
  7. 6. Проблема: permission denied на файлы логов
  8. 7. Диагностика
  9. 8. Защита от сканеров
  10. Итог: чеклист

Reverse proxy на Caddy: сайт, HTTPS и защита от сканеров на Ubuntu 26.04

Шпаргалка по reverse proxy на сервере, который уже настроен по статье о первичной настройке и защите: что такое reverse proxy и зачем он нужен, подготовка DNS и портов, установка Caddy, Caddyfile для статики и для приложения, автоматический HTTPS от Let’s Encrypt, проблема с логами, диагностика и fail2ban против сканеров, которые перебирают /.env и /.git. Это не экспертный гайд, а то, что я сам сделал и на чём споткнулся.

Исходные данные

Сервер после первичной настройки: Ubuntu 26.04, пользователь demo с sudo, вход по ключу, в UFW открыт только порт SSH, fail2ban с тюрьмой sshd. Два сайта:

  • example.com — статика (сайт-карточка и блог), готовые файлы в /var/www/example;
  • app.example.com — приложение, слушает 127.0.0.1:3000 (у меня это контейнер, Docker — следующая статья).

example.com, app.example.com, you@example.com и <IP-сервера> подставляем свои.

💡 Все команды выполняем от demo, где нужен root — с sudo.


1. Что такое reverse proxy и зачем он

Reverse proxy — программа, которая стоит между интернетом и твоими сайтами. Все запросы из интернета приходят к ней на порты 80 и 443, а она по имени сайта в запросе решает, что с ним делать: отдать файлы с диска или передать запрос приложению, которое слушает только локальный адрес.

Интернет :80/:443 → Caddy (на хосте) → /var/www/example    статика
                                     → 127.0.0.1:3000      приложение

Зачем это нужно:

  1. Один вход на все сайты. На сервере может быть сколько угодно сайтов и приложений, но наружу открыты только 80 и 443. Приложение слушает 127.0.0.1 — loopback, адрес «только для самого сервера», — и из интернета до него не добраться физически, даже если firewall настроен с ошибкой.
  2. HTTPS в одном месте. Сертификаты, шифрование, редирект с HTTP на HTTPS делает прокси. Приложению не нужно ничего знать про TLS.
  3. Единая точка контроля. Логи всех запросов, заголовки безопасности, баны сканеров — всё в одном месте, а не в каждом приложении по отдельности.

1.1. Почему Caddy, а не nginx

Caddynginx
HTTPSАвтоматически: сам получает и продлевает сертификатыcertbot отдельно + таймер продления
Конфиг на 2 сайта~15 строк~60 строк
HTTP/2, HTTP/3Из коробкиh2 настраивается, h3 требует отдельной настройки
Готовые рецепты в сетиМеньшеОгромная база
Тонкий тюнинг под нагрузкуМеньше рычаговБольше контроля

Для 1–5 сайтов без экзотики беру Caddy: главное — не надо помнить про продление сертификатов. nginx — когда нужна сложная маршрутизация, тонкий тюнинг или он уже знаком.


2. Подготовка до установки

Caddy при первом запуске сразу пойдёт получать сертификаты. Чтобы это прошло с первого раза, до установки должны быть готовы три вещи: DNS, открытые порты и понимание лимитов.

⚠️ Периметр должен быть готов до выпуска сертификата. Каждый выпущенный сертификат попадает в публичные логи Certificate Transparency — открытый реестр всех сертификатов, без записи в нём браузеры сертификату не доверяют. Сканеры следят за этими логами в реальном времени. У меня первый бот пришёл на новый домен через 1 секунду после выпуска сертификата, хотя домен ещё нигде не публиковался. «Пока про сайт никто не знает» не бывает — поэтому сначала первичная настройка и защита сервера, потом сертификат.

2.1. DNS указывает на сервер

Зачем: Let’s Encrypt выдаёт сертификат, только убедившись, что домен твой: он обращается к домену и проверяет, что отвечает именно твой сервер. Если DNS ещё указывает в другое место (или запись не успела разойтись), проверка провалится.

dig +short example.com A
dig +short www.example.com A
dig +short app.example.com A

dig спрашивает у DNS, какой адрес у имени. A — запись с IPv4-адресом, +short — вывести только сам адрес, без служебной информации.

  • ✅ Каждая команда вернула <IP-сервера>
  • ❌ Другой IP или пусто → правим DNS у регистратора и ждём, Caddy не запускаем
dig +short example.com AAAA

AAAA — запись с IPv6-адресом. Let’s Encrypt проверяет сначала по ней, если она есть. Проверяем так же каждое имя.

  • ✅ Пусто (IPv6 не используешь) или IPv6-адрес твоего сервера
  • ❌ Чужой IPv6 → удалить запись у регистратора или исправить на адрес сервера

⚠️ Caddy получает сертификат на каждое имя отдельно: имя, которое не резолвится, останется без сертификата и будет сыпать ошибками в лог, остальные выпустятся. Поэтому проверяем все имена до запуска.

2.2. Порты

Зачем: сейчас в UFW открыт только порт SSH. Чтобы сайт был доступен, нужно открыть веб-порты: 80 — HTTP, 443 — HTTPS.

sudo ufw allow 80/tcp comment 'HTTP'
sudo ufw allow 443/tcp comment 'HTTPS'
sudo ufw allow 443/udp comment 'HTTP/3'

comment — подпись к правилу, видна в ufw status. Через полгода по ней сразу понятно, зачем порт открыт.

sudo ufw status numbered

Список правил UFW с номерами — проверяем, что новые правила на месте.

  • ✅ Для каждого порта по две строки: v4 и (v6)

⚠️ Порт 80 нужен даже для сайта только на HTTPS. Через него Let’s Encrypt проверяет, что домен твой: обращается к http://домен/.well-known/acme-challenge/… и ждёт ответа от Caddy. Закроешь 80 — сертификат не выпустится, а уже выпущенный не продлится. Заодно Caddy на 80 отвечает редиректом на HTTPS.

💡 443/udp — для HTTP/3: он работает поверх UDP (протокол QUIC), а не TCP. Caddy включает его по умолчанию. Без этого правила сайт работает, просто браузеры остаются на HTTP/2.

2.3. Let’s Encrypt и его лимиты

Let’s Encrypt — бесплатный центр сертификации. Сертификат от него браузеры считают доверенным. Получение и продление идут по протоколу ACME: программа на сервере (здесь — Caddy) сама запрашивает сертификат, проходит проверку владения доменом и получает его. Никаких ручных действий.

Чтобы его не заваливали запросами, у Let’s Encrypt есть лимиты:

ЛимитЗначение
Неудачных проверок5 на имя для одного аккаунта в час
Сертификатов на домен50 за 7 дней
Одинаковых сертификатов (тот же набор имён)5 за 7 дней

Значения — по документации Let’s Encrypt на 2026-10-15.

Что это значит на практике: Caddy сам бережёт лимиты: при неудаче повторяет попытку через тестовый сервер Let’s Encrypt, потом пробует ZeroSSL, а паузы между попытками растут до суток. Поэтому, если запустить его с неготовым DNS, лимит он не исчерпает, но после исправления DNS сертификат может появиться только через несколько часов. Чтобы не ждать, DNS проверяем до запуска, а если уже запустили — после исправления делаем sudo systemctl reload caddy.

Для отладки есть тестовый центр сертификации с огромными лимитами. Его сертификаты браузер не примет, но проверить, что выпуск проходит, можно. Строку acme_ca добавляем в глобальный блок в начале Caddyfile (раздел 4.1), отдельный блок не создаём, иначе Caddy не разберёт файл:

{
	email you@example.com
	acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}

Когда всё заработало — строку acme_ca убрать и сделать reload (раздел 5.1).


3. Установка

3.1. Откуда ставить

В репозитории Ubuntu 26.04 есть пакет caddy, но версии 2.6.2 — это 2022 год, без исправлений и новых возможностей за несколько лет. Поэтому подключаем официальный репозиторий Caddy (он размещён на Cloudsmith) и ставим из него. Обновления потом приходят обычным apt upgrade.

3.2. Репозиторий

Чтобы apt доверял чужому репозиторию, ему нужны две вещи: ключ, которым подписаны пакеты, и адрес репозитория.

sudo apt install debian-keyring debian-archive-keyring apt-transport-https curl

Пакеты из официальной инструкции Caddy (она общая для Debian и Ubuntu). На Ubuntu curl уже стоит, apt-transport-https у меня тоже стоял (с образа провайдера); debian-keyring и debian-archive-keyring установятся — это ключи Debian, вреда нет. apt-transport-https — пустой переходный пакет: HTTPS в apt встроен давно.

curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg

Скачивает GPG-ключ Caddy. apt проверяет им подпись каждого пакета: пакет, подписанный не этим ключом, не установится.

  • curl -1sLf: -1 — только TLS 1.0 и новее, -s — без прогресс-бара, -L — идти по редиректам, -f — при ошибке сервера завершиться с ошибкой, а не сохранить страницу ошибки как ключ;
  • gpg --dearmor — переводит ключ из текстового вида в двоичный, который понимает apt;
  • -o …keyring.gpg — куда сохранить.
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list > /dev/null

Скачивает описание репозитория и кладёт его в список источников apt. В описании стоит signed-by= с путём к ключу из прошлого шага — так apt знает, каким ключом проверять именно этот репозиторий. any-version вместо кодового имени релиза — репозиторий общий для всех версий Debian и Ubuntu.

sudo chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg /etc/apt/sources.list.d/caddy-stable.list

Даёт всем право читать оба файла: apt проверяет подпись от служебного пользователя _apt — ему нужен ключ, а список источников читают и команды apt без sudo (apt policy).

3.3. Пакет

sudo apt update

Перечитать списки пакетов — теперь вместе с репозиторием Caddy.

sudo apt install caddy

⚠️ Без -y. apt может предложить удалить пакеты, которые конфликтуют с новым; с -y это произойдёт без вопроса. Поэтому перед подтверждением читаем блок The following packages will be REMOVED — у меня так однажды молча удалился UFW (подробно — в статье про Docker, раздел про iptables-persistent).

Пакет сам создаёт пользователя caddy, службу systemd и стартовый конфиг /etc/caddy/Caddyfile, который отдаёт заглушку на порту 80.

3.4. Проверка

systemctl is-active caddy && curl -I http://localhost

Служба запущена? Если да — запросить у неё заголовки ответа (-I) прямо с сервера.

  • ✅ active и в заголовках Server: Caddy
  • ❌ failed → sudo journalctl -u caddy -n 30 --no-pager (последние 30 строк лога службы)

💡 Caddy работает от пользователя caddy, а не от root. Порты ниже 1024 обычно может открывать только root; Caddy это разрешает строка AmbientCapabilities= юнита systemd: CAP_NET_BIND_SERVICE — право на «привилегированные» порты без остальных прав root, CAP_NET_ADMIN — чтобы увеличить буферы UDP для HTTP/3. Если Caddy взломают, у атакующего будут права пользователя caddy, а не root.


4. Caddyfile

Caddyfile — конфиг Caddy, /etc/caddy/Caddyfile. В нём по блоку на каждый сайт: какие имена, что делать с запросами, куда писать логи. Всё про сертификаты и HTTPS Caddy делает сам — в конфиге этого нет.

4.1. Конфиг на два сайта

sudo nano /etc/caddy/Caddyfile

Файл принадлежит root, поэтому редактор — через sudo. Заменяем содержимое целиком:

{
	email you@example.com
}

# статика
example.com, www.example.com {
	root * /var/www/example
	file_server
	encode zstd gzip

	log {
		output file /var/log/caddy/example.log {
			roll_size 10mb
			roll_keep 3
		}
	}
}

# прокси на приложение
app.example.com {
	reverse_proxy 127.0.0.1:3000

	log {
		output file /var/log/caddy/app.log {
			roll_size 10mb
			roll_keep 3
		}
	}
}

Разбор по блокам:

  • { email … } — глобальные настройки, блок без имени в начале файла. email — адрес аккаунта у Let’s Encrypt. Писем об истечении сертификатов Let’s Encrypt больше не шлёт (прекратил с 4 июня 2025), так что за продлением следим по логам Caddy (раздел 5.2).
  • example.com, www.example.com { … } — блок сайта. Имена через запятую обслуживаются одинаково, сертификат Caddy получает на каждое имя отдельно.
  • root * /var/www/example + file_server — отдавать файлы из этой папки. * — для всех путей.
  • encode zstd gzip — сжимать ответы, если браузер это умеет: страницы грузятся быстрее.
  • reverse_proxy 127.0.0.1:3000 — всё, что пришло на app.example.com, передаётся приложению. Заголовки X-Forwarded-For и X-Forwarded-Proto Caddy проставляет сам — по ним приложение видит реальный IP посетителя, а не 127.0.0.1 прокси.
  • log { output file … } — свой файл лога на каждый сайт. roll_size 10mb + roll_keep 3 — ротация: когда файл дорастёт до 10 МБ, он архивируется, хранятся три последних. Эти логи понадобятся для fail2ban (раздел 8).

Чего в конфиге нет — и это правильно:

  • сертификатов — Caddy получит их сам и сам продлит;
  • редиректа HTTP → HTTPS — Caddy делает его автоматически;
  • HTTP/2 и HTTP/3 — включены по умолчанию.

💡 Сейчас Let’s Encrypt выдаёт сертификаты на 90 дней, а к 2028 году срок сократится до 45. Caddy продлевает заранее и сам, на настройку это не влияет.

4.2. Папка сайта и тестовая страница

Зачем: если папки из root нет или она пустая, Caddy на любой запрос ответит 404 — и по нему не понять, работает ли HTTPS. Тестовая страница сразу показывает: сертификат есть, файлы отдаются. Потом её заменит настоящий сайт (деплой из CI).

sudo mkdir -p /var/www/example
echo '<h1>example.com работает</h1>' | sudo tee /var/www/example/index.html > /dev/null
  • mkdir -p — создать папку вместе с родительскими; если уже есть — без ошибки;
  • sudo tee — записать файл в папку, куда пишет только root; > /dev/null — не дублировать текст на экран.

Файлы принадлежат root, но читать их могут все (права 644) — Caddy от пользователя caddy их отдаст.

  • ✅ В 5.3 HTTPS отвечает 200
  • ❌ 404 → нет index.html в папке из root: ls -la /var/www/example

4.3. Полезные директивы

Не нужны с первого дня, но пригодятся: редирект с www, одностраничные приложения, кэш, пароль на раздел, заголовки безопасности.

Справка: редирект www, SPA, кэш, пароль, заголовки, healthcheck
# редирект www → без www: один адрес сайта для поиска и закладок
www.example.com {
	redir https://example.com{uri} permanent
}

# SPA — все несуществующие пути отдать как index.html, маршруты разбирает JS
try_files {path} /index.html

# статика с долгим кэшем в браузере (для файлов с хешем в имени)
@static path *.js *.css *.woff2 *.png *.jpg *.svg
header @static Cache-Control "public, max-age=31536000, immutable"

# вход по паролю; хеш получить командой caddy hash-password
basic_auth {
	user ХЕШ_ПАРОЛЯ
}

# заголовки безопасности: только HTTPS, без угадывания типов, без встраивания в чужие фреймы
header {
	Strict-Transport-Security "max-age=31536000; includeSubDomains"
	X-Content-Type-Options "nosniff"
	X-Frame-Options "SAMEORIGIN"
	Referrer-Policy "strict-origin-when-cross-origin"
	-Server
}

# прокси с проверкой здоровья: Caddy сам опрашивает приложение и не шлёт запросы упавшему
reverse_proxy 127.0.0.1:3000 {
	health_uri /api/health
	health_interval 10s
}

Если www.example.com вынесен в отдельный блок с редиректом, из блока статики его нужно убрать.

Все директивы, кроме блока www.example.com, вписываются внутрь блока сайта. После -Server проверка в 5.3 не увидит server: Caddy — это нормально.


5. Применение

Изменения в Caddyfile не действуют, пока Caddy его не перечитает. Порядок всегда один: проверить конфиг → перечитать → убедиться, что сертификаты выпущены → проверить снаружи.

5.1. Проверка конфига и reload

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

sudo -u caddy caddy validate --config /etc/caddy/Caddyfile

caddy validate разбирает конфиг и проверяет его, ничего не применяя. sudo -u caddy — выполнить от пользователя caddy, а не от root.

  • ✅ Valid configuration
  • ❌ Ошибка с номером строки → чинить, не перезагружать

⚠️ Почему от caddy, а не просто sudo. При проверке Caddy открывает файлы логов из блоков log (и создаёт их, если их нет): от root они создадутся с владельцем root, и потом сам сервис не сможет в них писать (раздел 6). От demo без sudo проверка упрётся в права на /var/log/caddy.

sudo systemctl reload caddy

reload — Caddy перечитывает конфиг без остановки: открытые соединения не рвутся, сайты не моргают.

💡 reload, а не restart: если в новом конфиге всё-таки ошибка, reload откатится и старый конфиг продолжит работать. restart в такой ситуации уронил бы оба сайта.

5.2. Выпуск сертификатов

Зачем: после reload Caddy в фоне идёт за сертификатами. Если что-то не так с DNS или портом 80, сайт не откроется по HTTPS — а узнать причину можно только из лога.

sudo journalctl -u caddy --since '2 min ago' --no-pager | grep -iE 'certificate obtained|error'

Лог службы Caddy за последние 2 минуты, только строки про выданные сертификаты и ошибки.

  • ✅ certificate obtained successfully для каждого имени
  • ⚠️ creating new account because no account for configured email is known to us с полем error внутри — не ошибка, уровень info: при первом запуске создаётся аккаунт у Let’s Encrypt
  • ❌ challenge failed / timeout during connect → DNS (раздел 2.1) или закрыт порт 80 (раздел 2.2)

5.3. Проверка снаружи

Зачем: с самого сервера всё может работать, а из интернета — нет (firewall, DNS, сертификат). Единственная честная проверка — с другой машины.

С локальной машины:

curl.exe -I https://example.com
curl.exe -I http://example.com

curl.exe — именно с .exe: в Windows PowerShell 5.1 просто curl — это псевдоним Invoke-WebRequest. -I — только заголовки ответа.

  • ✅ HTTPS: 200 и server: Caddy
  • ✅ HTTP: 308 и location: https://example.com/ — редирект на HTTPS работает

💡 308, а не 301: оба значат «переехало навсегда», но 308 обязывает браузер повторить запрос с тем же методом и телом. Для POST-форм это критично — с 301 браузер превратил бы POST в GET, и данные формы потерялись бы.


6. Проблема: permission denied на файлы логов

На этом я застрял дольше всего, поэтому подробно.

Симптом. caddy validate пишет Valid configuration, но сервис не стартует:

Job for caddy.service failed.
open /var/log/caddy/example.log: permission denied

При этом у папки владелец правильный:

ls -ld /var/log/caddy

-d — показать саму папку, а не её содержимое.

drwxr-xr-x 2 caddy caddy 4096 /var/log/caddy

Смотрим не папку, а файлы в ней:

ls -l /var/log/caddy/
  • ❌ Файлы с владельцем root → их создал запуск Caddy от root, вероятнее всего sudo caddy validate (раздел 5.1). Писать в папку caddy может, а в чужой файл — нет.
  • ✅ Владелец caddy → причина в другом, см. тест ниже.

Починка — вернуть файлы пользователю caddy:

sudo chown -R caddy:caddy /var/log/caddy
sudo systemctl restart caddy
systemctl is-active caddy

Здесь restart, а не reload: сервис упал, перечитывать нечего — его нужно запустить.

Диагностический тест, если владелец файлов правильный. Разделяет две причины: обычные права и ограничения самой службы.

sudo -u caddy touch /var/log/caddy/test.log && echo "РАБОТАЕТ" || echo "ЗАПРЕЩЕНО"

От пользователя caddy пробуем создать файл в папке логов.

РезультатПричинаРешение
ЗАПРЕЩЕНООбычные праваchown, права родительских папок
РАБОТАЕТ, но сервис падаетПричина не в правах на файлыполный текст ошибки: sudo journalctl -u caddy -n 30 --no-pager

💡 LogsDirectory= — для своих сервисов. Папку логов можно поручить systemd: при каждом старте он создаёт /var/log/имя с владельцем из User= юнита. Caddy это не нужно — его папку создаёт пакет. Пригодится для своего сервиса (ниже — myapp), который пишет логи в файлы.

sudo systemctl edit myapp

Открывает редактор для «довеска» к юниту (override): свои настройки поверх штатных, без правки файла юнита. Вписать вверху, между строками ### Anything between here and the comment below... и ### Edits below this comment will be discarded:

[Service]
LogsDirectory=myapp
LogsDirectoryMode=0755

LogsDirectory=myapp — при каждом старте systemd создаёт /var/log/myapp с владельцем из User= юнита. LogsDirectoryMode — права на папку.

Затем:

sudo systemctl daemon-reload
sudo systemctl restart myapp
  • daemon-reload — systemd перечитывает юниты. После systemctl edit лишний (он делает это сам, когда закрываешь редактор), оставлен для случая, когда override правили напрямую в файле;
  • restart, а не reload: LogsDirectory применяется при старте сервиса.

⚠️ Уже сломанную папку это не чинит. Если папка принадлежит нужному пользователю, systemd файлы внутри не трогает, даже если они от root (man systemd.exec, LogsDirectory=). Поэтому сначала sudo chown -R (выше), потом override.

💡 Override ложится в /etc/systemd/system/myapp.service.d/override.conf и переживает обновление пакета. Правка самого файла юнита из пакета — нет: пакет перезапишет её при обновлении.

💡 Запасной вариант: убрать блоки log из Caddyfile. Логи пойдут в журнал systemd (journalctl -u caddy): одно место, ротация сама, никаких проблем с правами. Но тогда fail2ban из раздела 8 придётся настраивать на журнал, а не на файлы.


7. Диагностика

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

sudo systemctl status caddy --no-pager                   # жива ли служба, последние строки лога
sudo journalctl -u caddy -f                              # лог в реальном времени (Ctrl+C — выход)
sudo -u caddy caddy validate --config /etc/caddy/Caddyfile   # конфиг без ошибок?
sudo caddy fmt --overwrite /etc/caddy/Caddyfile          # выровнять отступы в конфиге
sudo ss -tulpn | grep -E ':80|:443'                      # кто слушает порты 80/443
curl -s localhost:2019/config/ | jq                      # действующий конфиг через admin API

💡 Admin API — служебный интерфейс Caddy на localhost:2019, доступен только с самого сервера. Показывает конфиг, с которым Caddy работает прямо сейчас. Полезно, когда правил файл, а изменения не применились.

СимптомПричинаЧто делать
502 Bad GatewayПриложение не отвечаетsudo docker ps, логи приложения, порт в reverse_proxy
504 Gateway TimeoutПриложение приняло запрос, но не ответилоЛоги приложения
ERR_CONNECTION_REFUSEDДо Caddy не дошлоsudo ufw status, systemctl status caddy
Сертификат не выпускаетсяDNS / порт 80 / лимитdig, sudo ufw status, после исправления — sudo systemctl reload caddy
permission denied на логВладелец файлов логовРаздел 6
Конфиг не применилсяПравили не тот файлcurl localhost:2019/config/ — действующий конфиг

Где что лежит:

ПутьЧто
/etc/caddy/CaddyfileКонфиг
/var/lib/caddy/.local/share/caddy/certificates/Сертификаты и ключи
/var/lib/caddy/.config/caddy/autosave.jsonПоследний применённый конфиг в JSON
/var/log/caddy/Логи сайтов
localhost:2019Admin API, только с самого сервера

8. Защита от сканеров

Как только сайт появился, на него придут боты. Большинство безобидны (поисковики), но часть перебирает пути в поисках забытых секретов: /.env с паролями, /.git с исходниками, админки WordPress. Для SSH это уже решает fail2ban из статьи о первичной настройке — теперь то же самое для сайта: находим в логах Caddy тех, кто сыплет ошибками 404, и баним.

⚠️ Сканеры приходят в первые минуты после выпуска сертификата (его видно в логах Certificate Transparency, раздел 2). У меня первый бот пришёл через 1 секунду, первый сканер, перебиравший пути, — через 4,5 минуты, а до этого раздела я добрался через полчаса. Пока тюрьмы нет, сайт и приложение не должны отдавать ничего лишнего: .env, .git, отладочные страницы.

8.1. Сначала посмотреть, кто ходит

Зачем: прежде чем банить, нужно понять, что происходит, — иначе легко забанить своих посетителей или поисковик. Логи Caddy пишутся в формате JSON, по строке на запрос; разбирать их удобно утилитой jq. На Ubuntu Server jq обычно уже стоит (приходит вместе с fwupd). Если jq: command not found — sudo apt install jq.

sudo tail -200 /var/log/caddy/example.log | jq -r '.request.uri' | sort | uniq -c | sort -rn | head -20

Какие пути запрашивают чаще всего. tail -200 — последние 200 запросов, jq -r '.request.uri' — достать из каждой строки путь, sort | uniq -c | sort -rn — посчитать одинаковые и отсортировать по убыванию.

У меня такой вывод — боты уже пришли:

     33 /
      4 /favicon.ico
      3 /v2/_catalog
      3 /trace.axd
      3 /telescope/requests
      3 /server-status
      3 /server
      3 /s/<id>/_/;/META-INF/maven/com.atlassian.jira/jira-webapp-dist/pom.properties
      3 /login.action
      3 /info.php
      3 /graphql/api
      3 /graphql
      3 /ecp/Current/exporttool/microsoft.exchange.ediscovery.exporttool.application
      3 /debug/default/view?panel=config
      3 /console/
      3 /config.json
      3 /api/graphql
      3 /api/gql
      3 /api
      3 /actuator/env

/ — заходы на главную, часть из них — пока сайт был пустой (раздел 4.2). Остальное — сканеры, сайт к этому моменту работал около получаса. Что они ищут:

ПутиЧто ищут
/.env, /config.json, /js/env.js, /actuator/envКонфиги с паролями и ключами API
/.git/config, /.git/HEAD, /.DS_Store, /.vscode/sftp.jsonЗабытые служебные файлы: исходники, пароли к серверу
/telescope/requests, /trace.axd, /info.php, /server-status, /debug/…Отладочные страницы Laravel, ASP.NET, PHP, Apache, Yii
/xmlrpc.php, /wp-json/WordPress
/login.action, …/jira-webapp-dist/pom.properties, /ecp/…Jira, Confluence, Exchange — версии с известными уязвимостями
/v2/_catalog, /graphql, /apiОткрытый Docker Registry и API

На статике найти нечего — всё это 404. Но если такой файл окажется в папке сайта или приложение отдаст отладочную страницу, найдут его именно так.

sudo tail -200 /var/log/caddy/example.log | jq -r '.request.headers."User-Agent"[0]' | sort | uniq -c | sort -rn | head -15

Кто ходит: User-Agent — строка, которой браузер или бот представляется.

У меня такой вывод:

     92 Mozilla/5.0 (l9scan/2.0.<id>; +https://leakix.net)
      6 Mozilla/5.0 (compatible; CT-WP-Scanner/1.0; +https://example.com/bot)
      4 Mozilla/5.0 (compatible; ForestEngine/1.0; +https://forestengine.net/)
      4 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36
      4 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.6422.60 Safari/537.36 Edge/12.246
      3 curl/8.17.0
      3 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36
      2 nameserver-checker-http-validator/1.0
      2 Mozilla/5.0 (iPhone; CPU iPhone OS 26_3_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/144.0.7559.95 Mobile/15E148 Safari/604.1
      2 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.5938.132 Safari/537.36
      1 understory/0.1 (CT research; https://github.com/0x4b) +contact-via-repo
      1 Mozilla/5.0 (compatible; SpiderSiteBot/1.0)
      1 Mozilla/5.0 (compatible; Databot)
      1 Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36
      1 Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36

Часть ботов представляется честно (l9scan — сканер LeakIX, ForestEngine, Databot, CT research), но много и строк обычного Chrome и Edge. Боты неплохо маскируются под пользователей — поэтому ниже банить будем по поведению, а не по User-Agent.

💡 <id> в User-Agent l9scan и в пути Jira — метка сканера LeakIX, а в ней закодирован IP цели: ASCII-коды символов адреса (a.b.c.d) в hex, записанные задом наперёд. При публикации логов это тоже адрес твоего сервера — заменять, как и сам IP.

sudo grep -E '"status":40[034]' /var/log/caddy/example.log | jq -r '"\(.request.remote_ip) \(.request.uri)"' | sort | uniq -c | sort -rn | head -20

Кто получает ошибки 400/403/404 и на каких путях: пары «IP — путь».

У меня такой вывод:

      4 <мой-IP> /
      2 185.218.86.21 /xmlrpc.php?rsd
      2 185.218.86.21 /wp-json/
      2 185.218.86.21 /
      1 95.153.31.124 /.env.example
      1 95.153.31.124 /.env
      1 203.0.113.10 /
      1 203.0.113.11 /
      1 64.89.160.19 /js/env.js
      1 64.89.160.19 /config.json
      1 64.89.160.19 /config.js
      1 64.89.160.19 /api/config
      1 64.89.160.19 /README.md
      1 64.89.160.19 /CLAUDE.md
      1 64.89.160.19 /AGENTS.md
      1 64.89.160.19 /.github/copilot-instructions.md
      1 64.89.160.19 /.env
      1 203.0.113.12 /
      1 203.0.113.13 /
      1 203.0.113.14 /favicon.ico

<мой-IP> с / — это я сам, пока сайт был пустой. 64.89.160.19 ищет конфиги и заодно файлы инструкций для AI-ассистентов (CLAUDE.md, AGENTS.md, copilot-instructions.md) — в них бывают пути, имена серверов и ключи. 203.0.113.x — заглушки вместо адресов, с которых был только / или favicon.ico: это могли быть живые посетители. Адреса сканеров оставлены как есть.

КатегорияКтоЧто делать
ПолезныеGooglebot, YandexBot, Bingbot, TelegramBot (превью ссылок)Пускать: без них сайта нет в поиске
НейтральныеАкадемические сканеры, мониторинги, RSSИгнорировать
ВредныеПеребор /.env, /.git/HEAD, /wp-adminБанить

💡 Типичная картина: один IP за секунды даёт 20+ вариантов /.env и /.git/HEAD, а живой посетитель — пару ошибок (обычно favicon.ico). Разделить их порогом легко.

⚠️ Не блокировать по User-Agent — он подделывается одной строкой.

⚠️ Сначала починить свои 404, потом банить. Если favicon.ico отдаёт 404 каждому посетителю, порог будут набирать живые люди:

sudo grep -E '"status":404' /var/log/caddy/example.log | jq -r '.request.uri' | sort | uniq -c | sort -rn | head

Какие пути чаще всего отдают 404 — среди них ищем свои недостающие файлы. Их (favicon.ico, robots.txt) положить в папку сайта (/var/www/example; если сайт собирается из исходников — в их public/), а не исключать в фильтре.

8.2. robots.txt

Что это: файл в корне сайта, в котором ты просишь ботов не ходить в определённые места. Честные боты (поисковики) его соблюдают. Он нужен, чтобы поисковики правильно индексировали сайт, — и сам по себе убирает лишние 404 на /robots.txt.

Для личного сайта достаточно:

User-agent: *
Allow: /
Sitemap: https://example.com/sitemap-index.xml

Всем ботам можно всё, карта сайта — по этому адресу.

Закрыть AI-краулеры (по желанию):

User-agent: GPTBot
Disallow: /
User-agent: CCBot
Disallow: /
User-agent: ClaudeBot
Disallow: /
User-agent: Google-Extended
Disallow: /

💡 Google-Extended запрещает использовать сайт для обучения Gemini, не влияя на поиск. Disallow для Googlebot выкинул бы сайт из выдачи.

⚠️ robots.txt — не защита, а просьба. Сканер, который перебирает /.env, его даже не читает. И это публичный список того, что ты хочешь скрыть: Disallow: /admin сообщает всем, что /admin у тебя есть.

⚠️ Если сайт деплоится из CI (у меня так), robots.txt правим в репозитории (public/robots.txt), а не на сервере: деплой с rsync --delete снесёт ручные правки.

8.3. fail2ban на логи Caddy

Как это работает: fail2ban следит за файлом лога, ищет в нём строки по шаблону (фильтр), считает совпадения по каждому IP и, если их больше порога за заданное время, банит IP в firewall (тюрьма). Для SSH всё это было готово из коробки, для Caddy фильтр и тюрьму пишем сами.

Шаг 1. Структура лога — от имён полей зависит шаблон:

sudo tail -1 /var/log/caddy/example.log | jq '{ip: .request.remote_ip, uri: .request.uri, status: .status}'

Последняя строка лога, из неё — только IP, путь и код ответа. Убеждаемся, что поля называются именно так.

Шаг 2. Фильтр — какие строки считать «плохими»:

sudo tee /etc/fail2ban/filter.d/caddy-404.conf > /dev/null << 'EOF'
[Definition]
failregex = ^\{.*"remote_ip":"<HOST>".*"status":40[034].*\}$
ignoreregex = .*"uri":"/(favicon\.ico|apple-touch-icon.*\.png|robots\.txt)"
datepattern = "ts":{EPOCH}
EOF
ЧастьСмысл
failregexСтрока лога с кодом 400, 403 или 404 — «плохая»
<HOST>Место, откуда fail2ban возьмёт IP для бана
40[034]400 (битый запрос), 403 (запрет), 404 (перебор путей)
ignoreregexТо, что браузеры запрашивают сами, — за это банить нельзя
datepatternГде в строке время: Caddy пишет его как unix-время в поле ts

Шаг 3. Проверить фильтр до применения — прогон по реальному логу, как sshd -t для фильтров:

sudo fail2ban-regex /var/log/caddy/example.log /etc/fail2ban/filter.d/caddy-404.conf

У меня такой вывод (сокращённо):

Failregex: 85 total
|   1) [85] ^\{.*"remote_ip":"<HOST>".*"status":40[034].*\}$
Ignoreregex: 8 total
|   1) [8] .*"uri":"/(favicon\.ico|apple-touch-icon.*\.png|robots\.txt)"
Date template hits:
|  [179] "ts":{EPOCH}
Lines: 179 lines, 8 ignored, 85 matched, 86 missed
  • ✅ Failregex: N total → столько строк фильтр счёл «плохими»
  • ✅ Ignoreregex: N total → исключения работают
  • ✅ Date template hits равно числу Lines → время читается в каждой строке
  • missed — норма: это ответы 200, 308 и прочие, не подходящие под фильтр
  • ❌ 0 total, хотя 404 в логе есть → шаблон не совпадает со структурой лога

⚠️ Тюрьму включать, только когда сайт уже отдаёт страницы. Пока папка сайта пустая, 404 получают все, включая живых посетителей и тебя самого, — и тюрьма забанит своих. У меня из 85 «плохих» строк заметная часть — заходы на главную до того, как появился index.html.

Шаг 4. Тюрьма — где искать, по какому фильтру, с каким порогом и на сколько банить. Дописываем в jail.local из статьи о первичной настройке:

sudo tee -a /etc/fail2ban/jail.local > /dev/null << 'EOF'

[caddy-404]
enabled  = true
backend  = polling
filter   = caddy-404
logpath  = /var/log/caddy/*.log
maxretry = 10
findtime = 5m
bantime  = 24h
port     = http,https
protocol = tcp,udp
EOF

⚠️ tee -a — дописать в конец. Без -a файл перезапишется, и настройки sshd пропадут.

ПараметрПочему так
backend = pollingЧитать файл, а не журнал systemd — без этой строки тюрьма может ничего не ловить (8.4, пункт 1)
maxretry = 10Сканер набирает 20+, посетитель 1–2 — запас в обе стороны
findtime = 5mСканер проходит словарь за секунды
bantime = 24hДля перебора мягкость не нужна. Повторные баны растут по правилам [DEFAULT] (bantime.increment) до bantime.maxtime
logpath = /var/log/caddy/*.logМаска подхватит логи всех сайтов
port = http,httpsТолько веб. SSH бан не затронет — себя не отрежешь
protocol = tcp,udpПо умолчанию бан ставится только на TCP. HTTP/3 идёт по UDP 443 (открыт в 2.2) — без этой строки забаненный продолжает ходить на сайт по HTTP/3

Шаг 5. Применить и проверить:

sudo fail2ban-client -t

Проверка конфигов fail2ban, как sshd -t: синтаксис и параметры, в том числе дубли опций.

  • ✅ OK: configuration test is successful
  • ❌ ERROR с текстом ошибки → перезапускать нельзя, сначала починить; option … already exists → 8.4, пункт 2
sudo systemctl restart fail2ban && sleep 5 && systemctl is-active fail2ban

Перезапуск и проверка, что поднялся. sleep 5 — fail2ban на Python стартует несколько секунд.

sudo fail2ban-client status caddy-404
  • ✅ active, в статусе тюрьмы есть File list: /var/log/caddy/…
  • ❌ Journal matches вместо File list → 8.4, пункт 1
  • ❌ is-active пишет failed → 8.4, пункт 2

Бан закрывает и TCP, и UDP — проверяем, какое правило fail2ban ставит в firewall. Ждать настоящего бана не нужно: вручную баним адрес из диапазона для документации (203.0.113.x, в интернете его нет).

sudo fail2ban-client set caddy-404 banip 203.0.113.99
sudo nft list ruleset | grep -B2 -A8 caddy-404
sudo fail2ban-client set caddy-404 unbanip 203.0.113.99
  • set caddy-404 banip — забанить адрес в тюрьме вручную, как будто он превысил порог;

  • nft list ruleset — все правила firewall; fail2ban пишет свои в таблицу f2b-table, grep оставляет нашу тюрьму, -B2 -A8 — строки до и после совпадения, чтобы видеть правило целиком;

  • unbanip — снять тестовый бан.

  • ✅ В f2b-chain две строки: tcp dport { 80, 443 } … reject и udp dport { 80, 443 } … reject

  • ❌ Только tcp dport → нет строки protocol = tcp,udp в тюрьме или fail2ban не перезапущен

Шаг 6. Проверка боем — забанить самого себя. Чтобы не ждать сутки, временно ставим бан на минуту (меняется только в работающем демоне, файл не трогается):

sudo fail2ban-client set caddy-404 bantime 60

set … bantime 60 — время бана для тюрьмы caddy-404 в секундах, только до перезапуска fail2ban. С локальной машины — 12 запросов к несуществующим путям (порог — 10):

foreach ($i in 1..12) { curl.exe -s -o NUL -w "$i " "https://example.com/.env.test$i" }

foreach ($i in 1..12) — 12 повторов; -s — без прогресса, -o NUL — тело ответа в никуда (NUL — это /dev/null в Windows), -w "$i " — печатать номер запроса, чтобы видеть, что все 12 ушли. На сервере:

sudo fail2ban-client status caddy-404
  • ✅ Свой IP в Banned IP list, Total failed — 10 или больше (запросы после бана до сервера уже не доходят)
  • ✅ curl.exe -m 5 -I https://example.com с локальной машины не проходит (таймаут или отказ соединения); -m 5 — ждать не дольше 5 секунд, -I — только заголовки
  • ✅ SSH продолжает работать
  • ⏳ Через минуту бан снимается сам
  • ❌ Бана нет, Total failed: 0 → 8.4, пункт 1

⚠️ Бан появляется не мгновенно: polling проверяет файл раз в секунду, плюс время на обработку и бан. У меня — от доли секунды (11 запросов прошли, 12-й уже нет) до нескольких секунд. Пусто сразу после curl — подожди и проверь ещё раз.

💡 Статус смотреть, пока бан действует. Счётчики Total failed и Total banned живут в памяти fail2ban и обнуляются при перезапуске — после возврата bantime (ниже) там будут нули. История банов остаётся в логе:

sudo grep 'caddy-404' /var/log/fail2ban.log | tail -20
  • ✅ Строки Found <IP>, затем Ban <IP> и через минуту Unban <IP>

У меня такой вывод (сокращённо) — тюрьма поймала настоящий сканер:

14:42:40,826 fail2ban.filter  [7378]: INFO    [caddy-404] Found 20.211.97.120 - 2026-10-15 14:42:40
...                                           (ещё 8 строк Found за 3 секунды)
14:42:44,052 fail2ban.filter  [7378]: INFO    [caddy-404] Found 20.211.97.120 - 2026-10-15 14:42:43
14:42:44,220 fail2ban.actions [7378]: NOTICE  [caddy-404] Ban 20.211.97.120
14:43:43,456 fail2ban.actions [7378]: NOTICE  [caddy-404] Unban 20.211.97.120

10 «плохих» запросов за 3 секунды, бан — через 0,17 с после 10-го. Unban через минуту — потому что ещё стоял тестовый bantime 60 (см. ниже).

⚠️ Вернуть bantime сразу после проверки. Пока стоит 60, настоящие сканеры тоже отсиживают минуту вместо суток. У меня тестовое значение провисело 10 минут, и сканер, пришедший через 4 минуты после теста, fail2ban отпустил через минуту.

Вернуть боевое значение — перезапуск перечитает jail.local:

sudo systemctl restart fail2ban && sleep 5 && sudo fail2ban-client get caddy-404 bantime
  • ✅ 86400 (24 часа в секундах)

Управление банами:

sudo fail2ban-client status caddy-404
sudo fail2ban-client set caddy-404 unbanip <IP>  # разбанить
sudo fail2ban-client set caddy-404 banip <IP>    # забанить вручную

8.4. Если fail2ban не работает

Не шаг настройки — сюда, если в шаге 5 или 6 раздела 8.3 что-то не так. Два случая, на которые я наступал: симптом → проверка → причина → починка.

1. Тюрьма есть, но ничего не ловит.

Симптом — в sudo fail2ban-client status caddy-404:

|  |- Total failed:     0       ← хотя в логе полно 404
|  `- Journal matches:          ← вместо File list

Проверка — какой файл читает тюрьма:

sudo fail2ban-client get caddy-404 logpath
  • ❌ No file is currently monitored → тюрьма читает журнал systemd, logpath игнорируется

То же видно в логе fail2ban — при каждом старте он пишет, откуда читает тюрьма:

sudo grep "Jail 'caddy-404' uses" /var/log/fail2ban.log | tail -1
  • ✅ Jail 'caddy-404' uses poller {} — читает файл (backend = polling)
  • ❌ uses systemd → тюрьма читает журнал

Причина: у fail2ban есть backend — откуда он берёт события: systemd — журнал systemd (так настроен sshd), polling — перечитывать файл раз в секунду. Caddy пишет в файлы, а тюрьма без своей строки backend берёт его из настроек по умолчанию — и может достаться systemd. У меня на Ubuntu 24.04 systemd стоял в [DEFAULT] для всех тюрем; на 26.04 в [DEFAULT] стоит auto, а systemd прописан только для sshd.

Починка: в секции [caddy-404] в /etc/fail2ban/jail.local должна быть строка backend = polling (в шаге 4 она уже есть — значит, при копировании потерялась). Открыть файл и дописать её под [caddy-404]:

sudo nano /etc/fail2ban/jail.local
sudo systemctl restart fail2ban && sleep 5 && sudo fail2ban-client status caddy-404
  • ✅ В статусе File list: /var/log/caddy/…

2. fail2ban не стартует: option … already exists.

Симптом — sudo fail2ban-client -t выдаёт ошибку вместо OK. Если перезапустили без проверки — systemctl is-active fail2ban пишет failed, а в sudo journalctl -u fail2ban -n 20 --no-pager:

ERROR Failed during configuration: While reading from '/etc/fail2ban/jail.local'
[line 14]: option 'backend' in section 'caddy-404' already exists

Причина: одна и та же опция дважды в одной секции. Обычно — повторный запуск команды, которая дописывает строку в файл.

Проверка — найти дубли с номерами строк:

grep -n 'backend' /etc/fail2ban/jail.local

Вместо backend — имя опции из ошибки. Две строки в одной секции — это дубль.

Починка: удалить строку по номеру из ошибки и перезапустить:

sudo sed -i '14d' /etc/fail2ban/jail.local
sudo systemctl restart fail2ban && sleep 5 && systemctl is-active fail2ban

sed -i '14d' — удалить 14-ю строку прямо в файле.

💡 У меня дубль появился от команды sed -i '/^\[caddy-404\]/a backend = polling' («вставить строку после [caddy-404]»), запущенной дважды стрелкой вверх. Такие команды не идемпотентны — каждый запуск добавляет ещё одну строку. Замена s/старое/новое/ идемпотентна: второй раз заменять уже нечего.


Итог: чеклист

  • DNS всех имён указывает на сервер, записи A и AAAA (dig +short …)
  • Порты 80/tcp, 443/tcp, 443/udp открыты (sudo ufw status)
  • Caddy работает (systemctl is-active caddy)
  • В папке сайта есть index.html (ls -la /var/www/example)
  • Конфиг валиден (sudo -u caddy caddy validate --config /etc/caddy/Caddyfile)
  • Сертификаты выпущены (certificate obtained successfully в sudo journalctl -u caddy)
  • Снаружи: HTTPS — 200, HTTP — 308 на HTTPS (curl.exe -I …)
  • Логи пишутся, файлы принадлежат caddy (ls -l /var/log/caddy/)
  • Приложение слушает только 127.0.0.1 (sudo ss -tulpn | grep 3000)
  • fail2ban: тюрьма caddy-404 читает файлы (sudo fail2ban-client status caddy-404 → File list)
  • fail2ban: бан закрывает и UDP (banip 203.0.113.99 → в sudo nft list ruleset есть udp dport { 80, 443 })
  • fail2ban: после проверки боем bantime вернулся на сутки (sudo fail2ban-client get caddy-404 bantime → 86400)