Ваш сервис на домене клиента: CNAME, certbot и 300 строк Go вместо ACME on-demand

от автора

Есть класс фич, которые выглядят на одну строчку в тикете и на неделю в реализации. «Хочу, чтобы статус-страница жила на моём домене» — из таких. Клиент добавляет CNAME status.client.ru → status.your-service.com, и дальше начинается интересное: кто выпустит сертификат для чужого домена, кто его продлит, как не дать одному пользователю положить всю схему мусорными доменами и почему X-Forwarded-For может превратить внутренний эндпоинт в публичный.

Я делал это для сервиса мониторинга — у него есть публичные статус-страницы, и веб-студии просили отдавать их клиентам white-label, со своего домена. Расскажу, какую схему выбрал, покажу код и соберу грабли, на которые наступил или почти наступил. Сеттинг прагматичный: один сервер, nginx перед Go-приложением, никакого Kubernetes.

Почему не ACME on-demand

Первое, что приходит в голову — Caddy или openresty с auto-ssl: пришёл запрос с неизвестным Host, на лету выпустили сертификат, закешировали. Схема рабочая, но у неё три свойства, которые мне не понравились.

Приватные ключи оказываются в руках приложения. On-demand-выпуск означает, что процесс, который терминирует TLS, умеет ходить в Let’s Encrypt и хранит ключи. Если это ваш application-сервер — у него становится слишком много прав. Если отдельный Caddy — у вас появляется ещё один stateful-компонент, который надо бэкапить и мониторить.

DoS через мусорные Host-заголовки. Любой, кто наведёт CNAME на ваш IP (или просто пришлёт запрос с выдуманным Host), заставляет вас дёргать Let’s Encrypt. У LE лимит — 300 новых заказов на аккаунт за 3 часа. Исчерпать его чужими руками — значит оставить без сертификатов настоящих клиентов. On-demand-решения от этого защищаются allow-листом из базы, но тогда «на лету» уже не совсем на лету — появляется та же связка «проверь домен в базе, потом выпускай», что и у меня, только внутри TLS-хендшейка, где ей не место.

Выпуск в момент хендшейка — худший момент. Первый посетитель платит за выпуск сертификата секундами ожидания, а если LE прилёг — получает обрыв соединения.

Мне же терять нечего: домены и так лежат в базе (пользователь вводит их в настройках), значит, можно выпускать сертификаты заранее, спокойно, по расписанию. Асинхронность здесь — не компромисс, а честное отражение реальности: между «вписал домен в настройки» и «добавил CNAME у регистратора» проходят минуты, а то и сутки.

Схема целиком

Четыре части, каждая маленькая:

пользователь вводит домен        │        ▼[1] нормализация и валидация ввода          (Go, ~40 строк)        │  domain_status = 'pending'        ▼[2] фоновый DNS-верификатор                  (Go, горутина, каждые 5 мин)        │  CNAME указывает на нас? → 'verified'        ▼[3] root-helper на systemd-таймере           (bash, ~70 строк)        │  certbot webroot → nginx-блок из шаблона → callback 'active'        ▼[4] HostRouter в приложении                  (Go, ~50 строк)           запрос с Host = client.ru → отдать его страницу с корня

Разделение ролей жёсткое: приложение никогда не трогает сертификаты и nginx, helper никогда не ходит в базу. Общаются они через два loopback-эндпоинта. Теперь по частям.

1. Валидация ввода: скучно, пока не станет больно

Пользователь вводит домен в текстовое поле, а дальше эта строка попадёт в SQL, в имя файла nginx-конфига и в аргументы certbot. Поэтому нормализация — не косметика:

func normalizeCustomDomain(s string) (string, string) {    d := strings.ToLower(strings.TrimSpace(s))    if d == "" {        return "", "" // пусто = убрать домен, это валидно    }    d = strings.TrimPrefix(strings.TrimPrefix(d, "https://"), "http://")    if i := strings.IndexAny(d, "/?#"); i >= 0 {        d = d[:i]    }    if i := strings.IndexByte(d, ':'); i >= 0 { // порт отбрасываем        d = d[:i]    }    d = strings.TrimSuffix(d, ".")    for _, r := range d {        if r > 127 {            return "", "используйте punycode-форму (xn--...) для кириллических доменов"        }    }    if len(d) > 253 || !hostnameRe.MatchString(d) {        return "", "некорректное имя хоста"    }    // свои домены не отдаём под клиентские страницы    for _, own := range []string{"your-service.com", "your-service.ru"} {        if d == own || strings.HasSuffix(d, "."+own) {            return "", "нельзя использовать домены сервиса"        }    }    return d, ""}

Три решения, которые стоит проговорить.

Кириллица отклоняется, а не конвертируется. Можно было молча прогнать через IDNA и принять «статус.клиент.рф». Я сознательно прошу punycode: пользователь должен видеть ровно ту строку, которую пропишет у регистратора, — иначе следующий час он будет выяснять, почему CNAME «не работает», сравнивая юникод с xn–.

Запрет собственных доменов. Без него пользователь вписывает app.your-service.com, проходит DNS-проверку (домен-то и правда указывает на ваш IP) — и его статус-страница перехватывает ваш личный кабинет. Это одна строчка защиты от целого класса неприятностей.

Пустая строка — валидное значение. Так пользователь снимает домен. Об этом легко забыть и получить форму, из которой домен нельзя убрать.

После сохранения домен получает статус pending — и на этом синхронная часть заканчивается. Никаких проверок в HTTP-обработчике: DNS ещё не настроен, и это нормально.

2. DNS-верификатор: CNAME, apex и гистерезис

Горутина раз в 5 минут проходит по всем доменам:

func (s *Server) checkDomainDNS(ctx context.Context, domain string) (bool, string) {    ctx, cancel := context.WithTimeout(ctx, 10*time.Second)    defer cancel()    res := net.DefaultResolver    if cname, err := res.LookupCNAME(ctx, domain); err == nil {        if strings.TrimSuffix(strings.ToLower(cname), ".") == s.cnameTarget {            return true, ""        }    }    // fallback для apex-доменов, где CNAME невозможен:    // A-записи домена пересекаются с A-записями цели    want, err1 := res.LookupHost(ctx, s.cnameTarget)    got, err2 := res.LookupHost(ctx, domain)    if err1 != nil {        return false, "цель не резолвится (проблема на нашей стороне)"    }    if err2 != nil {        return false, "домен не резолвится — добавьте CNAME " + s.cnameTarget    }    for _, ip := range got {        if contains(want, ip) {            return true, ""        }    }    return false, "DNS домена указывает не на нас — нужен CNAME " + s.cnameTarget}

Fallback по A-записям обязателен. RFC запрещает CNAME на apex-домене (там, где уже есть SOA/NS), поэтому client.ru — в отличие от status.client.ru — сможет указать на вас только A-записью. Проверка «пересекаются ли A-записи домена с A-записями цели» покрывает и этот случай, и DNS-провайдеров с ALIAS/ANAME-записями, которые разворачивают CNAME на своей стороне.

Тексты ошибок — это UI. Верификатор пишет причину в базу, интерфейс показывает её пользователю: «домен не резолвится — добавьте CNAME такой-то». Различать «вы ещё не добавили запись» и «у нас цель сломалась» — небольшая вежливость, экономящая переписку с поддержкой.

И самое важное решение этого слоя — гистерезис. Достигнутый статус никогда не понижается:

newStatus := r.statusif ok && r.status == "pending" {    newStatus = "verified"}// !ok на verified/active → пишем domain_error, статус НЕ трогаем

DNS — среда, где что-то моргает постоянно: у регистратора техработы, резолвер затупил, TTL истёк в неудачный момент. Если на каждый неудачный лукап деактивировать домен, работающая страница клиента будет случайным образом отваливаться. Поэтому временная пропажа DNS оставляет домен активным (сертификат-то на диске, nginx-блок на месте) — а в поле ошибки появляется предупреждение для владельца.

Обратная сторона: если клиент навсегда увёл DNS, страница честно умрёт сама — браузер просто перестанет приходить на наш IP. Деактивировать её в базе незачем.

3. Root-helper: 70 строк bash с точными правами

Выпускать сертификаты и перезагружать nginx должен root — но давать такие права приложению не хочется. Поэтому эта работа вынесена в отдельный скрипт на systemd-таймере (каждые 5 минут), который общается с приложением по HTTP через loopback:

json=$(curl -sf --max-time 10 "$HUB_URL/internal/status-domains") || exit 0domains=$(echo "$json" | grep -o '"domain":"[^"]*"' | cut -d'"' -f4)for d in $domains; do    # защита от инъекции в путь/конфиг: строго hostname    [[ "$d" =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ ]] || continue    want["$d"]=1    if [ ! -d "/etc/letsencrypt/live/$d" ]; then        certbot certonly --webroot -w "$ACME_WEBROOT" -d "$d" \            --non-interactive --agree-tos --keep \            --cert-name "$d" --deploy-hook "systemctl reload nginx" \            || continue   # не смог — попробуем в следующий цикл    fi    conf="$NGINX_DIR/status-${d}.conf"    [ -f "$conf" ] || { sed "s/__DOMAIN__/$d/g" "$TEMPLATE" > "$conf"; changed=1; }    curl -sf -X POST -d "{\"domain\":\"$d\"}" \        "$HUB_URL/internal/status-domains/activate" >/dev/nulldone# домены, снятые пользователем: конфиг убираем, сертификат не трогаемfor conf in "$NGINX_DIR/status-"*.conf; do    d=$(basename "$conf" .conf); d="${d#status-}"    [ -n "${want[$d]:-}" ] || { rm -f "$conf"; changed=1; }done[ "$changed" = 1 ] && nginx -t && systemctl reload nginx

Что здесь заслуживает внимания.

Regex на домен — вторая линия обороны. Да, приложение уже провалидировало ввод. Но helper работает root-ом и подставляет строку в путь файла и в shell-команду — проверить формат ещё раз перед этим стоит дешевле, чем разбирать последствия. Правило простое: каждый компонент валидирует входные данные сам, даже если «оттуда плохого прийти не может».

Сертификаты снятых доменов не отзываются. Когда пользователь убирает домен, helper удаляет только nginx-блок. Сертификат на диске безвреден, и дальше он живёт своей жизнью: если клиент увёл CNAME — очередной certbot renew провалит HTTP-01 и сертификат тихо истечёт через 90 дней; если CNAME остался — сертификат будет продлеваться вхолостую. Оба исхода я осознанно предпочёл логике отзыва с её краевыми случаями (а если домен вернули через день?). Единственная цена — шум в логах renew.

nginx -t перед reload — обязателен. Один кривой конфиг (например, сертификат выпустился, а файлы не дописались) без проверки уронит reload всех сайтов сервера.

Для HTTP-01-валидации чужих доменов нужен ещё один кусочек — catch-all на :80:

server {    listen 80 default_server;    server_name _;    location /.well-known/acme-challenge/ {        root /var/www/acme;    }    location / {        return 301 https://$host$request_uri;    }}

Когда LE приходит валидировать status.client.ru, запрос по CNAME прилетает на наш :80 — и должен найти challenge-файл, который certbot положил в общий webroot. Без default_server-блока certbot пройдёт валидацию только для доменов, у которых уже есть свой конфиг, — то есть никогда для новых.

DNS-01 вместо HTTP-01 здесь, кстати, не вариант: он требует писать TXT-записи в зону клиента, доступа к которой у нас нет и не будет. Wildcard тоже мимо — домены-то чужие, у каждого клиента свой.

4. HostRouter и ловушка loopback

Осталось отдать страницу. TLS терминирует nginx; шаблон блока для каждого домена — обычный proxy_pass на приложение с proxy_set_header Host $host. А в приложении запросы с чужим Host перехватывает обёртка над корневым mux:

func (s *Server) HostRouter(next http.Handler) http.Handler {    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {        slug, ok := s.customDomainSlug(r.Context(), r.Host) // кэш 30с        if !ok {            next.ServeHTTP(w, r)            return        }        switch {        case r.URL.Path == "/" || r.URL.Path == "":            r2 := r.Clone(r.Context())            r2.URL.Path = "/status/" + slug            next.ServeHTTP(w, r2)        case strings.HasPrefix(r.URL.Path, "/status/"):            next.ServeHTTP(w, r) // ассеты страницы: графики, RSS, бейдж        default:            http.NotFound(w, r) // дашборд и API на чужом домене не светим        }    })}

Ключевая строчка — последний default. На домене клиента живёт ровно одна страница и её ассеты; всё остальное — 404. Без этого по status.client.ru/login открывался бы ваш личный кабинет: с валидным сертификатом клиента, на его домене. Фишерам такой подарок делать не надо.

Резолв Host → страница кэшируется целиком на 30 секунд — доменов десятки, таблица крошечная, зато ни одного запроса в базу на горячем пути. В кэш попадают и verified-домены, не только active: сертификат уже может быть выпущен, а activate-колбэк от helper-а ещё не дошёл — страница должна ожить с первого TLS-запроса, а не после следующего цикла таймера.

И последняя грабля — самая коварная. Helper ходит в приложение по loopback, и эндпоинты /internal/* должны быть доступны только ему. Первая версия проверки выглядела очевидно:

ip := remoteIP(r)if !ip.IsLoopback() { http.NotFound(w, r); return }

Проблема: nginx проксирует тоже с 127.0.0.1. Внешний запрос на https://your-service.com/internal/status-domains приходит в приложение с loopback-адресом — и проверка его пропустит. Отличить можно по прокси-заголовкам: nginx их ставит всегда (это его конфиг), прямой curl helper-а — никогда:

if ip == nil || !ip.IsLoopback() ||    r.Header.Get("X-Forwarded-For") != "" || r.Header.Get("X-Real-Ip") != "" {    http.NotFound(w, r)    return}

Да, это опора на конфигурацию nginx — уберут proxy_set_header X-Real-IP, и защита ослабнет. Для внутреннего эндпоинта на одном сервере компромисс приемлемый; параноидальный вариант — отдельный порт, слушающий только 127.0.0.1, или unix-сокет.

Ограничения схемы

Всё выше — прагматика одного сервера. Границы применимости такие:

  • Несколько серверов за балансировщиком — helper с локальным certbot уже не годится: сертификат нужен на всех нодах. Тогда либо централизованное хранилище сертификатов (и мы на полпути к Caddy/cert-manager), либо TLS-терминация на балансировщике, у которого свой ACME.

  • Тысячи доменов — цикл «все домены каждые 5 минут» и кэш всей карты в памяти перестанут быть смешными. Понадобится очередь и инкрементальная обработка.

  • Rate limit LE всё ещё существует: 300 заказов за 3 часа. У меня выпуск случается только на переходе pending → verified, то есть по факту подтверждённого владения, поэтому упереться сложно. Но массовый онбординг (миграция сотни доменов за вечер) упрётся — на этот случай стоит держать паузу между выпусками.

На моём масштабе — сотни доменов, один сервер — схема живёт незаметно: пользователь вводит домен, добавляет CNAME, минут через десять страница открывается по https на его домене. Ни одна из частей не превышает сотни строк, каждую можно прочитать за чашку кофе, и падение любой из них не роняет остальные: перестал ходить таймер — новые домены не активируются, но все работающие работают.

По-моему, это и есть главный критерий для инфраструктурного кода, который трогаешь два раза в год.

ссылка на оригинал статьи https://habr.com/ru/articles/1078312/