Перейти к содержанию

Custom Domain — полное руководство

Кастомный домен позволяет пользователям платформы открывать свой туннель по собственному адресу (например myshop.com) вместо стандартного slug.dix.su.


Архитектура

Браузер → https://myshop.com/
    → nginx (SNI: myshop.com)
      ssl_certificate     /etc/nginx/custom-certs/myshop.com/fullchain.pem
      ssl_certificate_key /etc/nginx/custom-certs/myshop.com/privkey.pem
      (загружается per-handshake через $ssl_server_name)
    → proxy_pass http://dixu_proxy:4000
      Host: admin-dixsu.dix.su          # slug определяется здесь
      X-Custom-Domain: myshop.com       # реальный домен сохраняется здесь
    → dixu_proxy InterstitialPlug
      parse_slug(conn.host)             # → "admin-dixsu"
      get_req_header(conn,"x-custom-domain") → "myshop.com"
    → device_client (WebSocket → TCP relay → localhost:PORT на устройстве)

Важно: nginx не терминирует TLS по $ssl_server_name-переменной при первом запросе — cert/key грузится per-handshake. Это позволяет добавлять новые домены без перезапуска nginx.


Таблица в БД: custom_domains

Колонка Описание
user_id владелец
slug slug пользователя (для построения check-URL)
domain кастомный домен
active TRUE после выдачи сертификата
dns_verified TXT-запись проверена
verify_txt_value одноразовый токен dixsu-verify=<token>
cert_status pending / issued / failed
cert_expires_at дата истечения Let's Encrypt сертификата
attribution_verified ссылка на dix.su в футере сайта подтверждена
domain_changed_at дата последней смены домена (для 14-дн. кулдауна)
domain_changes_this_month счётчик смен в текущем месяце (лимит 2)

Процесс верификации (шаги 1–5)

Шаг 1: Пользователь вводит домен

POST /api/domain/setCustomDomainService.setDomain(): - Проверяет eligibility (роль pro или выше) - Проверяет кулдаун: если domain_changed_at < now - 14 days — ошибка - Проверяет месячный лимит: если domain_changes_this_month >= 2 — ошибка - Создаёт/обновляет запись: active=FALSE, dns_verified=FALSE, генерирует verify_txt_value (UUID 20 символов)

Шаг 2: Пользователь добавляет DNS-записи у регистратора

Пользователю показываются:

CNAME (обязательно для SSL):

Хост:    @  (или пустой, или myshop.com — зависит от панели)
Тип:     CNAME
Значение: admin-dixsu.dix.su

TXT (для подтверждения владения):

Хост:    _dixsu  (ОТНОСИТЕЛЬНЫЙ, без домена — НО некоторые панели
                  регистраторов требуют FQDN: _dixsu.myshop.com)
Тип:     TXT
Значение: dixsu-verify=<token>

Нюанс хоста TXT: у разных регистраторов разная логика: - Большинство (Namecheap, GoDaddy, reg.ru): вводить _dixsu → создаётся запись _dixsu.myshop.com - Некоторые (Cloudflare, часть панелей хостинга): вводить _dixsu.myshop.com (FQDN) → тоже создаётся _dixsu.myshop.com - Если добавить _dixsu.myshop.com как хост у регистратора, который автоматически добавляет домен, получится _dixsu.myshop.com.myshop.com — неправильно. Именно поэтому в интерфейсе показана подсказка с обоими вариантами.

Наш бэкенд (checkTxtRecord) при проверке всегда запрашивает _dixsu.<rootDomain>, где rootDomain — последние 2 метки домена. Например: для sub.myshop.com → проверяет _dixsu.myshop.com.

Шаг 3: Проверка DNS (TXT)

POST /api/domain/verify-txtcheckTxtRecord(): - Извлекает rootDomain (последние 2 метки) из domain - Запрашивает TXT-записи для _dixsu.<rootDomain> через dnsjava - Ищет запись вида dixsu-verify=<token> - При успехе: dns_verified = TRUE

Задержка DNS-пропагации: от минут до 48 часов. Среднее — 5–30 минут.

Шаг 4: Проверка attribution (ссылка на dix.su)

POST /api/domain/check-attributioncheckAttributionForUser(): - Находит запись с dns_verified=TRUE - Строит check-URL: https://<slug>.dix.su/ (через обычный туннель, не через кастомный домен — потому что SSL сертификата ещё нет) - Делает HTTP GET с таймаутом 10s - Ищет в теле ответа ссылку: href="https://dix.su" или href="https://www.dix.su" - При успехе: attribution_verified = TRUE

Нюанс SSL-курицы и яйца: проверка attribution делается через https://<slug>.dix.su/, а не через https://myshop.com/, потому что сертификат для кастомного домена ещё не выдан. Если делать через https://myshop.com/, получим SSL-ошибку.

Шаг 5: Выдача SSL-сертификата (certbot-agent)

certbot-agent.py (в контейнере dixsu-certbot-1) работает в цикле каждые 60 секунд:

  1. GET /internal/custom-domain/pending-certs → список доменов с dns_verified=TRUE AND attribution_verified=TRUE AND active=FALSE
  2. Для каждого домена вызывает:
    certbot certonly --webroot -w /var/www/certbot -d myshop.com
    
    (webroot challenge — файл кладётся в /var/www/certbot/.well-known/acme-challenge/)
  3. Копирует файлы из /etc/letsencrypt/live/myshop.com/ в /etc/nginx/custom-certs/myshop.com/
  4. ВАЖНО: chmod 644 privkey.pem — certbot пишет ключ как 600 (только root), а nginx worker работает как uid=nginx
  5. POST /internal/custom-domain/cert-issuedactive = TRUE
  6. Регенерирует /etc/nginx/custom-certs/custom-domain-map.conf

Нюанс при пересборке nginx: nginx читает ключ per-handshake. После docker compose up -d --build nginx, если privkey.pem стал 600 снова (смена владельца volume при rebuild) — nginx выдаст Permission denied. Решение: - docker-entrypoint.sh автоматически делает chmod 644 на все privkey.pem при каждом старте контейнера - При ручном восстановлении: docker exec dixsu-certbot-1 chmod 644 /etc/nginx/custom-certs/myshop.com/privkey.pem

Активация в nginx

После regen_map() обновляется файл custom-domain-map.conf:

map $host $custom_domain_slug {
    myshop.com  admin-dixsu;
    default     "";
}

Nginx перезагружается автоматически каждые 5 минут (фоновый цикл в docker-entrypoint.sh). Можно принудительно:

docker exec dixsu-nginx-1 nginx -s reload


Интерстициал при кастомном домене

При первом визите на https://myshop.com/ nginx проксирует запрос в dixu_proxy с заголовками:

Host: admin-dixsu.dix.su       # slug-хост для маршрутизации
X-Custom-Domain: myshop.com    # реальный домен

InterstitialPlug читает X-Custom-Domain и показывает myshop.com в заголовке интерстициала и title страницы, а не admin-dixsu.dix.su.

Cookie dixsu_seen=1 ставится через /_dixsu/proceed — относительный URL, браузер отправляет запрос на https://myshop.com/_dixsu/proceed, cookie привязывается к myshop.com (не к admin-dixsu.dix.su).


Лимиты и кулдауны

Ограничение Значение Применяется к
Смен в месяц 2 Смена на новый домен
Кулдаун между сменами 14 дней Смена на новый домен
Отключить/включить тот же домен без лимита Только деактивация

Счётчик domain_changes_this_month сбрасывается в 0 автоматически (через CRON или при смене, если дата смены в другом месяце — логика на стороне бэкенда).


Обновление (renewal) сертификатов

certbot-agent.py раз в сутки запускает certbot renew --quiet, который обновляет все сертификаты за 30 дней до истечения. После renewal: - Файлы копируются в /etc/nginx/custom-certs/<domain>/ - chmod 644 privkey.pem применяется - regen_map() пересоздаёт map-файл - nginx нужно перезагрузить — произойдёт автоматически через ≤5 минут


Диагностика

# Проверить статус домена в БД
docker exec dixsu-postgres-1 psql -U dixsu -c \
  "SELECT domain, active, dns_verified, attribution_verified, cert_status,
          cert_expires_at, domain_changed_at
     FROM custom_domains WHERE domain='myshop.com';"

# Логи certbot-agent
docker logs dixsu-certbot-1 --tail=50

# Проверить права на ключ
docker exec dixsu-certbot-1 ls -la /etc/nginx/custom-certs/myshop.com/

# Принудительно исправить права (если nginx не открывает ключ)
docker exec dixsu-certbot-1 chmod 644 /etc/nginx/custom-certs/myshop.com/privkey.pem
docker exec dixsu-nginx-1 nginx -s reload

# Проверить TXT-запись DNS
dig TXT _dixsu.myshop.com +short

# Проверить CNAME
dig CNAME myshop.com +short

# Проверить SSL-сертификат
echo | openssl s_client -connect myshop.com:443 -servername myshop.com 2>/dev/null \
  | openssl x509 -noout -dates -subject

Известные подводные камни

  1. Map.copyOf() + nullable columns: R2DBC возвращает null для nullable DB-колонок. Map.copyOf() (Java 10+) выбрасывает NPE при null-значениях. Решение: использовать LinkedHashMap с фильтрацией null.

  2. TXT-запись для subdomains: для домена sub.myshop.com TXT-хост проверяется как _dixsu.myshop.com (корневые 2 метки), а не _dixsu.sub.myshop.com.

  3. SSL chicken-and-egg: attribution не проверять через кастомный домен до выдачи сертификата — делать через slug.dix.su.

  4. privkey.pem 600 → 644: certbot создаёт ключ с правами 600. Нужен chmod после каждого копирования (issue_cert + renew_all в certbot-agent, и при старте nginx-контейнера через entrypoint).

  5. nginx per-handshake cert loading: nginx грузит cert/key при каждом TLS-handshake через ssl_certificate $cert_path; ssl_certificate_key .... Это позволяет добавлять домены без reload, но означает что permission-ошибка видна только при входящем соединении, не при старте.

  6. Webroot challenge и nginx: certbot кладёт challenge-файлы в /var/www/certbot/.well-known/acme-challenge/. nginx должен обслуживать /.well-known/acme-challenge/ для кастомного домена через HTTP (порт 80) — CNAME уже должен указывать на наш сервер.

  7. device_client reconnect после rebuild: при пересборке контейнера dixu_proxy WebSocket device_client обрывается. Клиент переподключается автоматически (обычно < 1 мин). В это время сайт доступен через интерстициал но proxying к устройству недоступен.