Перейти к содержимому

Статус-страница на домене клиента: Cname, certbot и сервис на go rhandza

Статус-страница на домене клиента: CNAME, Certbot и небольшой сервис на Go вместо ACME on-demand

Функция "разместить статус-страницу на собственном домене" выглядит элементарно: клиент добавляет запись `status.client.ru → status.your-service.com`, после чего страница должна открываться по красивому white-label-адресу. На практике за этой строкой скрываются выпуск TLS-сертификата, его продление, проверка DNS, защита от злоупотреблений и безопасная маршрутизация запросов.

Я реализовал такую схему для сервиса мониторинга с публичными статус-страницами. Архитектура получилась достаточно простой: один сервер, nginx перед приложением на Go, без Kubernetes и отдельного сложного оркестратора. Главный принцип - не выпускать сертификат непосредственно во время TLS-handshake, а заранее обрабатывать домены в фоне.

Почему ACME on-demand оказался не лучшим выбором

Первой идеей обычно становится Caddy или OpenResty с автоматической выдачей сертификата при первом запросе. Клиентский домен неизвестен заранее, приходит запрос, система обращается к Let's Encrypt, получает сертификат и сохраняет его.

Такой вариант действительно работает, но создаёт сразу несколько проблем.

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

Во-вторых, появляется удобный канал для DoS. Злоумышленник может отправлять запросы с вымышленными значениями `Host` или направить любой принадлежащий ему домен на ваш IP. Каждый такой запрос потенциально запускает обращение к центру сертификации. У Let's Encrypt есть лимит - 300 новых заказов на один аккаунт за три часа. Если потратить его на мусорные домены, реальные клиенты временно останутся без сертификатов.

Можно добавить allow-list и разрешать выпуск только для доменов из базы. Но тогда автоматический выпуск уже не является полностью динамическим: перед обращением к ACME всё равно приходится проверять домен. Только происходит это в наиболее неподходящий момент - внутри TLS-handshake.

Наконец, первый пользователь получает задержку в несколько секунд. Если центр сертификации или DNS работают нестабильно, посетитель видит ошибку соединения. В нашем случае такой риск был неоправданным: домен клиент вводит в настройках заранее, значит, сертификат можно запросить асинхронно.

Общая архитектура

Схема состоит из четырёх изолированных частей:

1. веб-приложение принимает домен и хранит его состояние;
2. фоновый DNS-верификатор проверяет, указывает ли домен на инфраструктуру сервиса;
3. небольшой root-helper запускает Certbot и изменяет конфигурацию nginx;
4. nginx принимает HTTPS и передаёт запрос в Go-приложение.

Роли разделены намеренно:

- приложение не имеет доступа к сертификатам и конфигурации nginx;
- helper не обращается к базе данных;
- связь между компонентами идёт через два loopback-эндпоинта;
- операции с повышенными правами сведены к короткому скрипту с фиксированными аргументами.

После сохранения домен получает состояние `pending`. HTTP-обработчик не пытается сразу проверить DNS и не ждёт выпуска сертификата. Это важно: пользователь может добавить запись у регистратора через несколько минут или даже на следующий день.

Валидация доменного имени

Введённая строка используется сразу в нескольких опасных контекстах: попадает в SQL-параметр, участвует в имени файла nginx и передаётся Certbot. Поэтому нормализация должна быть строгой.

Практический набор правил выглядит так:

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

Кириллические домены я бы не конвертировал молча. Технически можно применить IDNA и превратить `статус.клиент.рф` в punycode, однако пользователь затем видит одну форму, а у регистратора вводит другую. Из-за этого сложнее диагностировать ошибки. Лучше явно попросить указать адрес в punycode.

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

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

Проверка DNS

Фоновая задача запускается, например, раз в пять минут и обрабатывает домены со статусами `pending`, `verifying` или `certificate_error`.

Для обычного поддомена проверяется CNAME:

```text
status.client.ru CNAME status.your-service.com
```

Сравнивать нужно не только строковое значение, но и итоговую DNS-цепочку. Следует учитывать завершающие точки, регистр и возможные промежуточные CNAME.

Проверка только CNAME недостаточна. Для apex-домена вроде `client.ru` классическая запись CNAME обычно невозможна, поскольку в зоне уже существуют `SOA` и `NS`. Поэтому клиент может использовать A-запись:

```text
client.ru A 203.0.113.10
```

Нужно получить A-записи клиентского домена и сравнить их с адресами, на которые указывает целевой домен сервиса. Такой fallback также покрывает DNS-провайдеров, предоставляющих ALIAS или ANAME.

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

Certbot и безопасный helper

Когда DNS подтверждён, приложение обращается к локальному helper-эндпоинту. Сам helper не знает о пользователях и не читает базу. Он получает строго ограниченный набор данных: проверенное доменное имя и требуемую операцию.

Вызов Certbot должен выполняться без shell-интерполяции. Нельзя собирать команду одной строкой и передавать её в `sh -c`. Аргументы формируются массивом, а имя домена повторно проходит валидацию непосредственно в helper.

Примерный набор операций:

- выпустить сертификат для домена;
- проверить наличие действующего сертификата;
- обновить конфигурацию nginx;
- удалить конфигурацию при отвязке домена.

Root-права нужны только там, где без них нельзя обойтись. У helper должен быть минимальный набор разрешений, фиксированный путь к бинарникам и жёсткие права на файл. Логи не должны содержать приватные ключи, токены и чувствительные параметры.

После успешного выпуска сертификата nginx получает отдельный конфигурационный фрагмент. Изменение конфигурации выполняется во временный файл, затем запускается проверка синтаксиса и только после успешного результата - атомарная замена и graceful reload.

Маршрутизация по Host

В nginx каждый клиентский домен можно направить в один общий upstream. Go-приложение получает заголовок `Host`, ищет соответствующую активную статус-страницу и возвращает нужный контент.

Однако нельзя бездумно доверять `X-Forwarded-For`, `X-Forwarded-Host` и похожим заголовкам. Если внешний клиент сможет подделать их, внутренний endpoint, рассчитанный только на localhost или доверенный прокси, может стать доступным извне.

Надёжнее всего:

- перезаписывать forwarding-заголовки на nginx;
- принимать их в приложении только от доверенного IP;
- не использовать `X-Forwarded-Host` как единственный источник идентификации домена;
- проверять фактический `Host` после нормализации;
- отделять публичные и административные маршруты.

Особое внимание нужно уделить loopback. Сервис, слушающий `127.0.0.1`, нельзя считать защищённым автоматически: неправильная настройка прокси может предоставить внешний доступ через публичный nginx. Административные endpoint-ы должны иметь отдельные location-блоки, ограничения по методам и дополнительную авторизацию.

Состояния и повторные попытки

Для домена удобно использовать конечный автомат:

- `empty` - домен не задан;
- `pending` - значение сохранено, DNS ещё не подтверждён;
- `dns_verified` - запись найдена;
- `certificate_pending` - ожидается выпуск;
- `active` - сертификат и nginx настроены;
- `dns_error` - запись отсутствует или ведёт не туда;
- `certificate_error` - выпуск завершился ошибкой;
- `disabled` - домен отключён.

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

Также необходима блокировка на домен. Иначе два параллельных worker-а могут одновременно запустить Certbot, изменить один и тот же конфигурационный файл и привести nginx в неконсистентное состояние.

Продление сертификатов

Продление лучше выполнять заранее, например, когда до окончания действия остаётся 30 дней. Не стоит ждать последнего момента: DNS может измениться, аккаунт центра сертификации - упереться в лимит, а nginx - не принять новую конфигурацию.

После renewal нужно:

1. проверить, что сертификат действительно обновился;
2. проверить права на файлы;
3. протестировать конфигурацию nginx;
4. выполнить graceful reload;
5. записать результат в журнал;
6. отправить уведомление при повторяющихся ошибках.

Полезно регулярно проверять не только дату окончания сертификата, но и соответствие имени домена, наличие цепочки доверия и доступность HTTPS снаружи.

Защита от мусорных доменов

Даже без ACME on-demand пользователи могут добавлять тысячи случайных доменов и перегружать DNS-верификатор. Поэтому стоит ограничить частоту операций, ввести квоты и удалять давно неактивные привязки.

Хорошая практика - проверять домен сразу при сохранении на базовом уровне, но сетевые запросы выполнять только worker-ом. Для каждого адреса нужно хранить время последней попытки и причину ошибки. Это предотвращает бесконечный цикл запросов к DNS и делает поведение системы предсказуемым.

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

Что даёт такой подход

Асинхронная схема немного сложнее, чем "выпустить сертификат по первому запросу", зато она лучше контролируется:

- TLS-handshake не зависит от доступности Let's Encrypt;
- приватные ключи не находятся в application-процессе;
- мусорные Host-запросы не запускают выпуск сертификата;
- DNS и ACME обрабатываются с повторными попытками;
- nginx меняется только после проверки конфигурации;
- права root сосредоточены в маленьком изолированном helper-е;
- состояние каждого домена видно оператору и самому клиенту.

Для одного сервера это вполне практичная архитектура. Она не пытается спрятать сложность автоматизации, а раскладывает её на независимые этапы: пользователь вводит домен, DNS подтверждает владение маршрутом, Certbot выпускает сертификат, nginx начинает принимать трафик, а приложение безопасно выбирает нужную статус-страницу по имени хоста.

Прокрутить вверх