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/set → CustomDomainService.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):
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-txt → checkTxtRecord():
- Извлекает 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-attribution → checkAttributionForUser():
- Находит запись с 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 секунд:
GET /internal/custom-domain/pending-certs→ список доменов сdns_verified=TRUE AND attribution_verified=TRUE AND active=FALSE- Для каждого домена вызывает:
(webroot challenge — файл кладётся в
/var/www/certbot/.well-known/acme-challenge/) - Копирует файлы из
/etc/letsencrypt/live/myshop.com/в/etc/nginx/custom-certs/myshop.com/ - ВАЖНО:
chmod 644 privkey.pem— certbot пишет ключ как 600 (только root), а nginx worker работает какuid=nginx POST /internal/custom-domain/cert-issued→active = TRUE- Регенерирует
/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:
Nginx перезагружается автоматически каждые 5 минут (фоновый цикл в
docker-entrypoint.sh). Можно принудительно:
Интерстициал при кастомном домене¶
При первом визите на https://myshop.com/ nginx проксирует запрос в
dixu_proxy с заголовками:
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
Известные подводные камни¶
-
Map.copyOf()+ nullable columns: R2DBC возвращает null для nullable DB-колонок.Map.copyOf()(Java 10+) выбрасывает NPE при null-значениях. Решение: использоватьLinkedHashMapс фильтрацией null. -
TXT-запись для subdomains: для домена
sub.myshop.comTXT-хост проверяется как_dixsu.myshop.com(корневые 2 метки), а не_dixsu.sub.myshop.com. -
SSL chicken-and-egg: attribution не проверять через кастомный домен до выдачи сертификата — делать через
slug.dix.su. -
privkey.pem 600 → 644: certbot создаёт ключ с правами 600. Нужен chmod после каждого копирования (issue_cert + renew_all в certbot-agent, и при старте nginx-контейнера через entrypoint).
-
nginx per-handshake cert loading: nginx грузит cert/key при каждом TLS-handshake через
ssl_certificate $cert_path; ssl_certificate_key .... Это позволяет добавлять домены без reload, но означает что permission-ошибка видна только при входящем соединении, не при старте. -
Webroot challenge и nginx: certbot кладёт challenge-файлы в
/var/www/certbot/.well-known/acme-challenge/. nginx должен обслуживать/.well-known/acme-challenge/для кастомного домена через HTTP (порт 80) — CNAME уже должен указывать на наш сервер. -
device_client reconnect после rebuild: при пересборке контейнера
dixu_proxyWebSocket device_client обрывается. Клиент переподключается автоматически (обычно < 1 мин). В это время сайт доступен через интерстициал но proxying к устройству недоступен.