Skip to content

ТЗ — Управление доменами (cms/domains)

Слой: 🔵 инфра-модуль · Зрелость доноров: ★ · Донор: proxy (инфра студии) Статус: ТЗ к разработке

Назначение и возможности

Привязка доменов и поддоменов к сайтам установки из админки с видимостью статуса SSL. Модуль не управляет DNS и сертификатами напрямую — он интегрируется с внешним контрактом reverse-proxy студии (Caddy + ISPmanager DNS API, proxy CLI) как клиент, вызывающий готовую инфраструктуру, а не дублирующий её. ACME-челленджи (HTTP-01/DNS-01) — целиком на стороне proxy-контракта: модуль их не реализует и не видит, только опрашивает финальный статус (valid/error) через dns publish/status --json.

  • Привязка домена/поддомена к сайту (cms/multisite) или ко всей установке;
  • отображение статуса SSL-сертификата (валиден/истекает/ошибка) по данным proxy;
  • запрос на публикацию поддомена через внешний контракт proxy dns publish (job, не синхронно);
  • запрос на снятие поддомена (proxy dns unpublish) с подтверждением;
  • журнал операций с доменами (кто и когда добавил/удалил привязку);
  • уведомление об истекающем сертификате (через cms/notifications-bus).

Зависимости и выключение

requires: — · suggests: cms/multisite, cms/notifications-bus · provides: domain-management

При выключении привязки доменов, сделанные ранее через инфраструктуру proxy, продолжают работать (изменение не откатывается) — теряется только UI управления ими из админки.

Модель данных

ТаблицаКлючевые поляПримечание
cms_domainsdomain, site_id (nullable), ssl_status, ssl_expires_at, provisioned_atЗеркало состояния из proxy-контракта
cms_domain_operations_logdomain, operation, status, user_id, created_atЖурнал publish/unpublish-операций

ssl_status — enum → PHP Enum (valid/expiring/error); site_idnullable()->constrained('cms_sites')->index() (связь с cms/multisite, если включён); domain — уникальный индекс. cms_domain_operations_log — журнальная append-only таблица, индекс по domain и BRIN по created_at при большом числе операций.

ПДн-паспорт. ПДн не храню — таблицы содержат доменные имена и user_id (ссылка на пользователя ядра, не ПДн-атрибут), без email, телефонов и иных персональных данных.

Входные и выходные данные

Whitelist-принцип (§11 стандарта): всё, что не перечислено во «Входах», модуль отвергает на границе FormRequest — включая неизвестные поля в теле запроса и произвольные значения ssl_status/operation не из перечисленных источников.

Входы

ИсточникДанные/поляЧем валидируется
Форма Filament: добавление доменаdomain, site_id (опц.)FormRequest: regex доменного имени (RFC 1035, лейбл ≤63), whitelist символов, ReDoS-валидатор ядра, site_idexists:cms_sites,id
Форма Filament: заявка на публикациюid доменаPolicy domains.manage, домен в статусе pending/error (не повторная публикация valid)
Форма Filament: заявка на снятие (unpublish)id домена, флаг подтвержденияPolicy domains.manage, модальное подтверждение (см. «UX-требования»)
API POST /api/v1/admin/domainsdomain, site_idFormRequest (та же схема, что и форма), Idempotency-Key (мутация с внешним эффектом)
API DELETE /api/v1/admin/domains/{id}idPolicy, домен принадлежит текущей установке
Событие SiteDomainChanged (cms/multisite)site_id, old_domain, new_domainСобытие cms/multisite (канал 1); домен повторно проходит regex-валидацию перед использованием во внешнем вызове
Ответ внешнего контракта proxy (dns publish/unpublish/status --json)status, ssl_status, expires_at, errorСхема JSON-ответа CLI-контракта; несовпадение схемы/версии → ошибка health-чека, запись не применяется вслепую
Плановый триггер сверки— (внутренний, без пользовательского ввода)ScheduleRegistrar ядра (auto_sync_interval_minutes)

Нормализация IDN. Домен с кириллицей (тест.рф) нормализуется в punycode (xn--...) на границе FormRequest до regex-валидации RFC 1035 и до внешнего вызова proxy — валидатор и уникальный индекс cms_domains.domain работают с punycode-формой; в Admin UI и API домен отображается обратно в unicode (конвертация только на выдаче, не в БД).

Выходы

ПотребительДанныеФормат
Admin UI (Filament)Список доменов с цветовым статусом SSL, журнал операцийТаблицы Filament, keyset-пагинация
API-клиент (domains.manage)Список доменов, статус SSL, история операцийJSON {data, meta}
cms/notifications-bus (если включён)Уведомление об истекающем сертификатеВызов notification-channel (payload domain, expires_at)
cms/multisite (если включён)Читаемое имя/домен сайта для site_idРезолв через provides-контракт multisite (не raw SQL)
Подписчики событий DomainPublishRequested/DomainPublished/DomainSslExpiringSoonФакт заявки/публикации/истеченияСобытие ядра (канал 1), payload — см. «События и обмен»
Внешний контур proxy (CLI/JSON)Заявка dns publish/unpublishJSON/CLI-вызов вне пяти внутренних каналов

Настройки (группа domains)

КлючТипДефолтaffectsPageCacheОписание
domains.ssl_expiry_warning_daysint14За сколько дней до истечения SSL предупреждать
domains.auto_sync_interval_minutesint60Периодичность сверки статуса с proxy
domains.default_targetstring""Контейнер/директория назначения по умолчанию для новых поддоменов
domains.publish_retry_attemptsint3Число автоматических повторов при ошибке публикации/выпуска SSL, прежде чем зафиксировать error
domains.auto_sync_enabledbooltrueKill-switch плановой сверки статуса с proxy (отключить при нестабильности внешнего API без выключения всего модуля)
domains.max_domainsint50Максимум доменов на инсталляцию (по аналогии с multisite.max_sites); достижение — понятная ошибка в форме добавления домена, не 500 и не тихий отказ

API

МетодПутьДоступНазначение
GET/api/v1/admin/domainsdomains.manageСписок доменов установки со статусом SSL (keyset-пагинация)
POST/api/v1/admin/domainsdomains.manageЗаявка на публикацию домена (job → внешний контракт proxy)
DELETE/api/v1/admin/domains/{id}domains.manageЗаявка на снятие домена
GET/api/v1/admin/domains/{id}/operationsdomains.manageЖурнал операций по домену (keyset-пагинация)

Компоненты

Filament: ресурс доменов (статус SSL цветовой индикацией, кнопки publish/unpublish), виджет «сертификаты на истечении». Команды: cms:domains:sync --json (сверка статуса с внешней инфраструктурой proxy), cms:domains:doctor --json.

Демо-контент. Сидер DomainsDemoSeeder — 1–2 домена в разных статусах SSL (valid, expiring) для playground и /_gallery, без похода во внешний контракт proxy (данные статичные); джобы в очередь domains не кладёт.

События и обмен

СобытиеКогдаPayload
DomainPublishRequestedОтправлена заявка на публикациюdomain, user_id
DomainPublishedПодтверждена публикация (DNS+SSL готовы)domain, site_id
DomainSslExpiringSoonСертификат истекает в пределах порогаdomain, expires_at

Реализует канонический контракт domain-management. DomainSslExpiringSoon резолвится в уведомление через notification-bus (cms/notifications-bus), если модуль включён. Читает site_id через сервис cms/multisite (если включён) — не раw SQL в его таблицы. Слушает: только собственные заявки publish/unpublish и периодическую сверку статуса с внешним контрактом proxy.

Взаимодействия

Сущность/модульКаналНаправлениеЧто происходит
cms/multisite (cms_sites)provides-контракт multisiteinРезолв читаемого имени сайта для site_id в UI; отсутствие модуля → fallback «весь сайт»
cms/multisite (SiteDomainChanged)событиеinСмена основного домена сайта — триггер на заявку публикации нового домена (см. «Крайние случаи»)
cms/notifications-busprovides-контракт notification-channeloutDomainSslExpiringSoon → уведомление админу; модуль выключен → log-fallback ядра
Очередь domains (PublishDomain/UnpublishDomain)очередьoutДжобы вызывают внешний контракт из воркера, не из HTTP-потока
cms/healthhealth-агрегатoutHealth-чек модуля сообщает о недоступности внешнего API proxy
Внешний контур proxy (dns publish/unpublish/status --json)внешний JSON/CLI-контракт (вне пяти каналов)out (запрос) / in (ответ)Модуль — клиент готовой инфраструктуры reverse-proxy студии, не издатель/потребитель внутренней шины; ACME-челлендж (HTTP-01/DNS-01) полностью скрыт внутри этого вызова — модуль получает только финальный valid/error

Фоновая работа

Очередь domains: джобы PublishDomain/UnpublishDomain (идемпотентны — повтор на уже опубликованном/снятом домене не дублирует эффект) вызывают внешний контракт proxy dns publish/unpublish --json только из очереди. Расписание сверки статуса (auto_sync_interval_minutes) — через ScheduleRegistrar ядра.

Мини-ранбук (типовые инциденты):

  1. SSL не переиздалсяcms_domain_operations_log за домен (причина последней ошибки) → cms:domains:sync --json вручную → повторный error — эскалация в proxy-контур, вне зоны модуля.
  2. Внешний API proxy недоступен на сверке → health-чек модуля в warning/fail → проверить доступность servers/proxy-nix отдельно от CMS — модуль лишь клиент, чинить на стороне cms/domains нечего, кроме повторной сверки после восстановления.
  3. Очередь domains отстаёт → проверить, жив ли воркер и длину очереди по метрике в Pulse/health.

Бэкап/рестор. В бэкап — cms_domains и cms_domain_operations_log. Сам факт публикации (DNS-запись, SSL-сертификат) бэкапом CMS не пересоздаётся — источник истины у proxy-контура со своим бэкапом; рестор считается завершённым только после прогона cms:domains:sync --json, который сверяет и поправляет статусы.

Производительность и кеш

Объёмы. Модуль обслуживает админку установки, не публичный трафик: реалистично единицы–десятки доменов на инсталляцию (основной + несколько поддоменов/алиасов на сайт cms/multisite), десятки–сотни — в крупной мультисайтовой инсталляции агентства. Не проектируется под тысячи доменов на одну CMS — это зона ответственности hosting-панели (proxy), не модуля. Жёсткий предел — domains.max_domains (дефолт 50): добавление сверх лимита отклоняется на границе FormRequest с понятной ошибкой, не 500 и не запись «на будущее».

Горячий путь. Список доменов со статусом SSL в админке (Filament-ресурс) — бюджет ≤3 запроса на страницу списка (домены + eager site.name + последняя запись журнала на домен, без N+1 по строкам). Публичный сайт домены не запрашивает вообще — резолвинг домен→сайт на публичном запросе делает cms/multisite, не этот модуль; cms/domains живёт только на пути административного экрана и фоновой сверки, не в цепочке page-cache ядра.

Индексы (сверка с «Модель данных»): уникальный индекс на cms_domains.domain (защита от дублей и быстрый lookup при publish/unpublish), индекс на site_id (фильтр списка по сайту), составной индекс (ssl_status, ssl_expires_at) под виджет «сертификаты на истечении» и job сверки по порогу ssl_expiry_warning_days. В cms_domain_operations_log — индекс по domain (журнал по конкретному домену) и BRIN по created_at (append-only, растёт линейно, диапазонные выборки).

Что кешируется и что нет. Собственных тегов кеша модуль не объявляет — осознанное решение: список доменов — низкочастотная админ-операция, а статус SSL обязан быть свежим для админа, принимающего решение о переиздании сертификата; кеш поверх редкого запроса добавил бы риск показать valid на истёкшем сертификате без выгоды в скорости. Сам SSL-статус уже «кеш с TTL»: cms_domains — зеркало, обновляемое job-сверкой раз в auto_sync_interval_minutes, а не на каждом обращении к proxy.

Инвалидация. Не применимо (тегов нет) — актуальность обеспечивается событиями DomainPublished/DomainSslExpiringSoon, обновляющими cms_domains напрямую, и периодической job-сверкой cms:domains:sync, а не CacheTags/событийной инвалидацией. На page-cache сайта модуль не влияет.

Безопасность

Входные границы: Filament/API (FormRequest на заявки publish/unpublish — валидация домена против path-traversal и битых имён, как в самом proxy-контракте), внешний вызов (proxy dns publish/unpublish) — только из очереди, не синхронно из HTTP-потока. Секретов (токен ISP API) модуль не хранит — они остаются на стороне инфраструктуры proxy, модуль лишь вызывает готовый CLI/JSON-контракт.

Права: domains.view, domains.manage.

Матрица ролей:

Рольdomains.viewdomains.manage
Администратор установки
Менеджер (доступ ко всей установке)
Редактор сайта (per-site, cms/multisite)✓ (только свой сайт, если site_id резолвится)— по умолчанию; при активном per-site скоупинге cms/multisite — только свой сайт
Studio

Конкретные векторы:

  • SSRF через параметр домена. domain передаётся во внешний вызов proxy dns publish --json <domain> как аргумент CLI, а не как часть URL, которым можно подменить хост запроса — но значение обязано пройти regex доменного имени (RFC 1035: буквы/цифры/дефис/точка, лейбл ≤63) на границе FormRequest до передачи наружу; без белого списка символов возможна инъекция флагов CLI (значения, начинающиеся с -/--) или строк, которые proxy-контракт интерпретирует как путь.
  • Path traversal в имени домена/поддомена. proxy-контракт транслирует домен в путь (конфиг Caddy, каталог сертификата) — домен с ../, %2e%2e, нулевым байтом или экранированными слэшами отклоняется на границе FormRequest ещё до похода во внешний вызов; модуль не полагается только на санитизацию со стороны proxy.
  • Unpublish чужого домена. Право domains.manage по умолчанию — на уровне установки, не сайта: осознанный дефолт (просто и безопасно из коробки), не пробел. Модуль готов к тонкому скоупингу через per-site модель прав, вводимую cms/multisite: когда она доступна и активна, domains.manage для конкретного сайта проверяется той же Policy-моделью, что и остальные per-site права редактора сайта. Без cms/multisite или без активного per-site скоупинга domains.manage остаётся правом всей установки — назначать его только ролям с доступом ко всей инсталляции (см. «Матрица ролей»).
  • IDN/punycode-домен как обход валидации. Домен с кириллицей (тест.рф) нормализуется в punycode (xn--...) на границе FormRequest до regex-валидации RFC 1035 и до передачи во внешний вызов proxy — валидатор проверяет punycode-форму, иначе символы вне ASCII-whitelist проходят под видом «национального домена». Каноническая форма хранится в cms_domains.domain; UI отображает домен обратно в unicode (см. «Входные и выходные данные»).
  • Rate-limit заявок. Publish/unpublish — per-пользователь rate-limit (защита от накрутки очереди и лишних запросов к внешнему API); повторная заявка на домен в статусе pending отклоняется на уровне FormRequest (см. «Входные и выходные данные»), не доходит до очереди.

UX-требования

  • Статус SSL — цветовой индикатор в списке (зелёный valid, жёлтый expiring, красный error) плюс дата истечения текстом; наведение/раскрытие строки — точная причина последней ошибки из cms_domain_operations_log.
  • Пустое состояние. Список без доменов — не пустая таблица, а подсказка «домены пока не привязаны» с кнопкой «добавить домен» (аудитория «админ», §12 стандарта: подсказка «что нажать», не голый пробел).
  • Publish/unpublish — не мгновенно. Заявка кладёт job в очередь domains; UI сразу переводит строку в «в процессе» (не «успех») с индикатором ожидания; реальный результат — по событию DomainPublished/ошибке job, обновление в списке (polling или realtime-transport). Пользователь не должен решить, что публикация «зависла», если она просто ждёт очереди.
  • Ошибка внешнего вызова — человеческим языком. Провал proxy dns publish/unpublish не показывается как «500»: сообщение вида «не удалось опубликовать sub.example.ru: <причина от proxy>» (текст ошибки контракта, не стектрейс) сохраняется в cms_domain_operations_log.status = error и видно в строке домена.
  • Подтверждение перед unpublish. Снятие домена может уронить сайт для его посетителей — модальное подтверждение с явным текстом последствия («домен <domain> перестанет открываться»), не тихий клик-и-готово.
  • Массовая пересверка. Список поддерживает выбор нескольких доменов для внепланового запуска сверки статуса (аналог cms:domains:sync из UI), не только по расписанию.

Крайние случаи и типовые баги

  • DNS указывает на CMS, домена нет ни в cms_domains, ни в cms_sites/cms_site_domain_aliases (cms/multisite) → при выключенном cms/multisite отдаётся единственный сайт установки (нет измерения site_id); при включённом — поведение по multisite.strict_domain_match: false (дефолт) — контент дефолтного сайта (fallback_to_default), true — отказ приложения (404, не 421: код 421 Misdirected Request корректен на уровне reverse-proxy/TLS SNI, которым владеет proxy/Caddy, а не Laravel-приложение — модуль его не эмулирует). После DomainPublished, если cms/multisite включён, модуль инициирует создание алиаса в таблицах cms/multisite (cms_sites/cms_site_domain_aliases) через provides-контракт multisite (канал 3, т.к. зависимость мягкая suggests), не прямой записью в чужую таблицу — без этого шага домен технически «опубликован» (DNS+SSL готовы), но сайт по нему не резолвится.
  • SSL не выпустился (proxy вернул ошибку — DNS не распространился, CAA блокирует и т.п.) → ssl_status = error, причина — в cms_domain_operations_log, видна админу (см. UX). Автоматический ретрай — domains.publish_retry_attempts попыток с нарастающей паузой через ту же очередь domains; после исчерпания — error финально и алерт через health-чек (cms/health), без бесконечного retry-луп.
  • Смена основного домена сайта (SiteDomainChanged от cms/multisite) → cms/domains создаёт заявку на публикацию нового домена (если ещё не опубликован), старый не снимается автоматически. 301-редирект и переключение канонических URL (мета, sitemap.xml) — ответственность SEO-подсистемы ядра (SEO); cms/domains только отражает факт в журнале операций.
  • Двойной клик «опубликовать» создаёт два параллельных job на один домен → дедупликация очереди не гарантирована средствами Laravel, поэтому идемпотентность — на стороне proxy-контракта (повторный dns publish уже опубликованного домена — no-op с тем же результатом, не ошибка); дополнительно UI блокирует повторный клик, пока статус домена «в процессе».
  • Внешний API proxy недоступен/таймаутит во время плановой сверки (cms:domains:sync) → job логирует сбой и не трогает ssl_status известных доменов (не затирает валидный статус ошибкой связи); health-чек уходит в warning/fail, UI показывает последний известный статус с меткой времени успешной сверки.
  • Домен уже занят другим сайтом установки (нарушение уникального индекса cms_domains.domain) → FormRequest проверяет конфликт до вставки, ответ 422 с текстом «домен уже используется», не 500 от необработанного QueryException.
  • cms/multisite выключен → поле site_id в форме скрыто, домен трактуется как «обслуживает всю установку» (см. «Зависимости и выключение»); при последующем включении cms/multisite ранее «общие» домены остаются с site_id = null и требуют ручной донастройки привязки — авто-угадывание сайта не выполняется.
  • Отзыв/удаление привязки домена во время исполнения job PublishDomain (гонка) → job доводится до конца (идемпотентно относительно внешнего состояния proxy), но перед применением результата проверяет текущий статус записи в БД; снятый/удалённый домен не «воскрешается» статусом valid — факт логируется в журнал как «применён к уже изменённому состоянию».
  • Apex-домен и его www.-поддомен — независимые строки cms_domains (нет автогруппировки apex/www); SSL и публикация запрашиваются отдельно для каждой, UI не выводит их как единую сущность.
  • Огромный журнал операций на инсталляции с частыми ошибками публикации → cms_domain_operations_log не highload-таблица при типовых объёмах (см. «Производительность и кеш»), но BRIN-индекс по created_at держит диапазонные выборки дешёвыми даже на десятках тысяч записей за годы эксплуатации.
  • Истечение регистрации домена у регистратора (не SSL-сертификата, а самой доменной записи — например, неоплаченный .ru через год) — вне зоны модуля: proxy dns status --json отдаёт только статус SSL/DNS-публикации, не срок регистрации. ssl_expiry_warning_days/DomainSslExpiringSoon — исключительно про сертификат; мониторинг регистрации домена у регистратора не входит в контракт proxy dns и не задача cms/domains.

Донорский код

Что взятьПуть
Контракт CLI/JSON для публикации поддоменовservers/proxy-nix — соседний с laravel/ каталог (/home/aleksey/projects/servers/proxy-nix): cli/isp-dns.cjs publish/unpublish --json

Донор servers/proxy-nix даёт только CLI-контракт публикации, не исторические данные: домены, привязанные вне CMS (вручную в Caddy/ISPmanager до внедрения модуля), заводит команда-концепт cms:domains:import-legacy --source=proxy-registry — сверяет список доменов proxy-контура (dns status --json по реестру) с cms_domains и создаёт недостающие записи сразу в статусе valid, без повторного dns publish. Идемпотентна (обновляет по ключу domain, не дублирует), поддерживает --dry-run с отчётом расхождений.

Тесты и приёмка

  • [ ] Контрактные тесты: заявка на публикацию домена корректно формирует вызов внешнего контракта proxy;
  • [ ] health-чек модуля проверяет доступность внешнего API проверки статуса (не блокирует само ядро при недоступности);
  • [ ] деградация при выключении — ранее опубликованные домены продолжают работать без UI;
  • [ ] права на управление доменами отделены от общих настроек сайта;
  • [ ] нет N+1 при выводе списка доменов со статусом SSL;
  • [ ] журнал операций сохраняет и неуспешные попытки публикации (для диагностики);
  • [ ] двойная заявка на публикацию одного домена не создаёт дублирующий эффект (идемпотентность job + блокировка повторного клика в UI);
  • [ ] конфликт уникальности домена возвращает 422 с понятным сообщением, не 500;
  • [ ] таймаут/недоступность внешнего API proxy при плановой сверке не искажает ssl_status уже известных доменов;
  • [ ] гонка «удаление привязки во время исполнения job публикации» не воскрешает снятый домен;
  • [ ] path traversal и SSRF-инъекция через поле domain отклоняются на границе FormRequest, не доходят до вызова proxy;
  • [ ] после DomainPublished при включённом cms/multisite создаётся алиас домена в его таблицах через provides-контракт (штатный сценарий, не заглушка);
  • [ ] превышение domains.max_domains отклоняется на границе FormRequest с понятной ошибкой, не создаёт запись и не идёт в очередь;
  • [ ] IDN-домен (кириллица) нормализуется в punycode до валидации и сохранения: каноническая форма в БД — punycode, отображение в UI — исходный unicode;
  • [ ] демо-сидер поднимает домены в разных статусах SSL без похода во внешний контракт proxy, виден в /_gallery/playground;
  • [ ] контрактный набор cms-testing зелёный, пакет протестирован в testbench-изоляции;
  • [ ] feature-тест на каждый роут API; тестовая БД только domains_test, migrate:fresh/refresh/reset запрещены.

Внутренняя база знаний студии