Тема
ТЗ — Управление доменами (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_domains | domain, site_id (nullable), ssl_status, ssl_expires_at, provisioned_at | Зеркало состояния из proxy-контракта |
cms_domain_operations_log | domain, operation, status, user_id, created_at | Журнал publish/unpublish-операций |
ssl_status — enum → PHP Enum (valid/expiring/error); site_id — nullable()->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_id — exists:cms_sites,id |
| Форма Filament: заявка на публикацию | id домена | Policy domains.manage, домен в статусе pending/error (не повторная публикация valid) |
| Форма Filament: заявка на снятие (unpublish) | id домена, флаг подтверждения | Policy domains.manage, модальное подтверждение (см. «UX-требования») |
API POST /api/v1/admin/domains | domain, site_id | FormRequest (та же схема, что и форма), Idempotency-Key (мутация с внешним эффектом) |
API DELETE /api/v1/admin/domains/{id} | id | Policy, домен принадлежит текущей установке |
Событие 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/unpublish | JSON/CLI-вызов вне пяти внутренних каналов |
Настройки (группа domains)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
domains.ssl_expiry_warning_days | int | 14 | — | За сколько дней до истечения SSL предупреждать |
domains.auto_sync_interval_minutes | int | 60 | — | Периодичность сверки статуса с proxy |
domains.default_target | string | "" | — | Контейнер/директория назначения по умолчанию для новых поддоменов |
domains.publish_retry_attempts | int | 3 | — | Число автоматических повторов при ошибке публикации/выпуска SSL, прежде чем зафиксировать error |
domains.auto_sync_enabled | bool | true | — | Kill-switch плановой сверки статуса с proxy (отключить при нестабильности внешнего API без выключения всего модуля) |
domains.max_domains | int | 50 | — | Максимум доменов на инсталляцию (по аналогии с multisite.max_sites); достижение — понятная ошибка в форме добавления домена, не 500 и не тихий отказ |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/admin/domains | domains.manage | Список доменов установки со статусом SSL (keyset-пагинация) |
| POST | /api/v1/admin/domains | domains.manage | Заявка на публикацию домена (job → внешний контракт proxy) |
| DELETE | /api/v1/admin/domains/{id} | domains.manage | Заявка на снятие домена |
| GET | /api/v1/admin/domains/{id}/operations | domains.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-контракт multisite | in | Резолв читаемого имени сайта для site_id в UI; отсутствие модуля → fallback «весь сайт» |
cms/multisite (SiteDomainChanged) | событие | in | Смена основного домена сайта — триггер на заявку публикации нового домена (см. «Крайние случаи») |
cms/notifications-bus | provides-контракт notification-channel | out | DomainSslExpiringSoon → уведомление админу; модуль выключен → log-fallback ядра |
Очередь domains (PublishDomain/UnpublishDomain) | очередь | out | Джобы вызывают внешний контракт из воркера, не из HTTP-потока |
cms/health | health-агрегат | out | Health-чек модуля сообщает о недоступности внешнего 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 ядра.
Мини-ранбук (типовые инциденты):
- SSL не переиздался →
cms_domain_operations_logза домен (причина последней ошибки) →cms:domains:sync --jsonвручную → повторныйerror— эскалация вproxy-контур, вне зоны модуля. - Внешний API
proxyнедоступен на сверке → health-чек модуля вwarning/fail→ проверить доступностьservers/proxy-nixотдельно от CMS — модуль лишь клиент, чинить на сторонеcms/domainsнечего, кроме повторной сверки после восстановления. - Очередь
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.view | domains.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запрещены.