Resolves plan item A.1 (option б): login/setup rate-limiting used RemoteAddr only, which behind the default reverse proxy is the proxy's own address, making the limiter effectively global and enabling a lockout-DoS. Now, when the request's direct peer matches the new TRUSTED_PROXY_CIDR list (comma-separated CIDRs, env, empty by default), the last X-Forwarded-For entry is used instead, giving a real per-client limit. Unset behaviour is unchanged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
60 KiB
План реализации: SelfPost
Статус: базовый линейный план (фазы 0→11, v1.0) выполнен и принят — см. progress.md (текущее состояние) и CHANGELOG.md (история релизов). Он здесь не повторяется.
Ниже остаётся только то, что ещё не сделано: открытые вопросы для согласования и опциональная линия 2.x.x.
Основа: specification.md v1.0.
Открытые вопросы — требует внимания и обсуждения (перед фиксацией v1.0)
Базовый план 0→11 выполнен и соответствует ТЗ (все обязательные пункты 7.6 подтверждены аудитом Фазы 11). Ниже — то, что выходит за букву ТЗ, но заслуживает решения перед тем, как считать v1.0 «финальным». Ничего из этого не является дефектом соответствия; это осознанные компромиссы и потенциальные улучшения. Каждый пункт — решение «делаем в v1.x / откладываем в 2.x / оставляем как есть», принимается пользователем.
A. Безопасность — hardening сверх обязательного 7.6
- Rate-limit за обратным прокси кеился по
RemoteAddr(internal/web/web.goclientIP). Решено: вариант (б) — парситьX-Forwarded-For, но только когда прямой peer (RemoteAddr) входит вTRUSTED_PROXY_CIDR(список CIDR через запятую, env, по умолчанию пусто); тогда используется последний элемент XFF (адрес, добавленный самим доверенным прокси). Без настройкиTRUSTED_PROXY_CIDRповедение не меняется (лимит поRemoteAddr, глобальный за прокси). См.deploy/.env.example. - Нет security-заголовков ответа — панель не шлёт
Strict-Transport-Security,Content-Security-Policy,X-Frame-Options/frame-ancestors,X-Content-Type-Options: nosniff,Referrer-Policy. XSS уже закрыт автоэкранированиемhtml/template(7.6.7), CSRF —SameSite=Lax, но заголовки — дешёвый второй эшелон (clickjacking, downgrade, sniffing). Вопрос: эмитить их из панели (единый мидлварь, ~10 строк) или переложить на reverse-proxy и задокументировать вdeploy/? Рекомендация — минимальный набор из панели (HSTS/nosniff/frame-ancestors 'none'/строгий CSPdefault-src 'self'), т.к. панель знает свою модель контента, а прокси у всех разный. - CSRF — только
SameSite=Lax, без токенов. Достаточно для современных браузеров (все мутации — POST, все GET read-only), но не защищает при downgrade до старого браузера/особых прокси и не даёт защиты на уровне «per-request». Вопрос: считатьSameSite=Laxдостаточным для single-admin панели (моя рекомендация — да) или добавить double-submit CSRF-токен. - Cookie без префикса
__Host-. Сейчасselfpost_session(Secure/HttpOnly/SameSite=Lax/Path=/). Префикс__Host-дал бы браузерный гарант «только HTTPS, только этот хост, без Domain». Мелочь, но бесплатная. Вопрос: переименовать (учесть dev-режимPANEL_COOKIE_SECURE=false—__Host-требуетSecure, т.е. только когда secure включён). - Setup-ссылка печатается в stdout контейнера. По ТЗ (7.6.1) — так и задумано, но если логи контейнера уезжают в агрегатор, токен там осядет на 10 минут. Файл
/data/setup-token(0600) — альтернатива. Оставить как есть (по ТЗ), но отметить в README, что для сред с централизованными логами предпочтителен файл. - Нет 2FA / смены пароля админа / нескольких админов из UI. ТЗ этого не требует (один админ, secret-link). Смена пароля сейчас — только пересоздание состояния. Решено: добавить раздел настроек аккаунта (логин + пароль) — см. Фазу 12 ниже. 2FA и мульти-админ остаются явно 2.x/вне объёма.
B. Надёжность и эксплуатация
- Сессии только в памяти — рестарт/редеплой разлогинивает админа (по ТЗ 9 допустимо). Плюс: absolute TTL 12ч без отдельного idle-timeout и без ротации токена при логине (session-fixation здесь неактуален, т.к. токен выдаётся только после аутентификации). Оставить; отметить поведение в README.
- Окно потери строк мониторингового лога при ротации (
copytruncate, Фаза 10) — несколько строкmail.logмогут потеряться в момент ротации. Приемлемо для мониторинга; зафиксировать как известное свойство. - Поведение при незаданном
SELFPOST_HOSTNAME— realm SASL и хост setup-ссылки падают вlocalhost. Для реального деплоя hostname обязателен. Вопрос: делать ли фатальную проверку «hostname обязателен» на старте (сейчас — мягкий fallback) — предложение: предупреждать громко в лог, но не падать.
C. CI и тесты
- CI не гоняет
go test. .github/workflows/release.yml на теге только собирает и пушит образ;go vetвыполняется внутри Dockerfile-сборки, но юнит-тесты в CI не запускаются — вся тестовая проверка идёт вручную на dev-сервере. Рекомендация: добавить обычный workflow на push/PR (go vet+go test ./...+gofmt -l), чтобы регресс ловился до тега релиза. Небольшая работа, заметно повышает доверие к «зелёному» релизу. - Нет интеграционного/e2e-теста в CI. Контейнерные e2e каждой фазы прогонялись вручную и задокументированы в git-истории, но не автоматизированы. Для v1.0 — вероятно, оставить ручными; для долгой поддержки — кандидат на smoke-тест (поднять контейнер, setup→login→add domain→auth SMTP) в CI.
D. Указатель на объём 2.x
- Входящий релей и pluggable-антиспам вынесены в опциональные фазы O1+ ниже (линия 2.x.x, вне v1.0, только по согласованию — ТЗ 12.6). Здесь перечислены лишь как напоминание, что это сознательно отложенный объём, а не забытый.
Фаза 12 (v1.x) — UI/UX: навигация, аккаунт, backup и параметры подключения
Статус: запланирована, не начата.
Цель: устранить конкретные недостатки панели, замеченные при использовании: (1) навигационная шапка не показывает, на какой странице находится админ; (2) нет способа сменить логин/пароль администратора после /setup, кроме пересоздания состояния; (3) полный бэкап и импорт домена свалены в один блок на дашборде, хотя это два разных по смыслу и риску действия; (4) страница домена не показывает сервер/порт/шифрование, нужные для настройки клиента отправки; (5) значения, которые нужно переносить во внешние сервисы (DKIM-запись, пароль приложения), нельзя скопировать одной кнопкой; (6) поле «Addresses» показывается даже в режиме, где оно не используется.
A. Навигационная шапка обязательна на каждой странице панели
Правило (закладывается на будущее, не только для текущих страниц): навигационная шапка —
обязательный элемент каждой аутентифицированной страницы панели, без исключений. Чтобы это не
превращалось в чек-лист «не забыть добавить нав в очередной новый шаблон» (как уже случилось с
domain_detail.html/domain_delete.html, см. ниже, и как иначе случилось бы с будущими /account,
/backup, /status из этой же и следующей фазы) — нав должен рендериться структурно, из
layout.html (общей обёртки всех страниц), а не копипастой
в каждом content-шаблоне. Тогда гарантия «шапка есть везде» не зависит от того, вспомнил ли автор
конкретной страницы её вставить.
Сейчас же топбар (Domains/Send log/Queue/Log) продублирован вручную и по-разному в разных
content-шаблонах (каждый определяет {{define "content"}} независимо, layout.html их просто
оборачивает, см. layout.html:81):
- dashboard.html, queue.html, sendlog.html, logtail.html содержат нав-ссылки, но без выделения текущей страницы, и текущая страница обычно не включает ссылку на саму себя (например,
queue.htmlне содержит пункта «Queue»), из-за чего создаётся впечатление, что кнопка «пропадает», а не выделяется; - domain_detail.html и domain_delete.html — самая посещаемая страница панели (там живут DKIM-запись, приложения, лимиты) — вообще не содержат нав-ссылок: топбар там ограничен
{{.User}}/«Sign out», и чтобы перейти на Send log/Queue/Log, нужно сначала вернуться на «← All domains». Именно это отсутствие и стало поводом сформулировать правило выше как общее, а не как точечный фикс двух файлов.
Реализация:
- Вынести
{{define "nav"}}и вызвать его изlayout.html, один раз, до{{template "content" .}}— не из отдельных content-шаблонов. Заодно туда же уходит и{{.User}}/«Sign out», которые сейчас тоже продублированы в топбаре каждого шаблона — так они тоже перестают зависеть от того, вспомнили их вставить или нет. - Content-шаблоны сохраняют только то, что специфично для страницы (заголовок
<h1>, специфичные действия вроде будущего «Reload» на/status) — без списка нав-ссылок и без{{.User}}/Sign out. - Добавить в данные каждого шаблона поле
Active string("domains"/"queue"/"sendlog"/"logtail", позже —"status"/"account"/"backup") — простая правка вhandleDashboard,handleQueue,handleSendLog,handleLogTail,handleDomainDetail,handleDeleteConfirm, а также во всех новых хендлерах, которые появятся в этой и следующей фазе.layout.htmlполучает.Activeнаравне с остальными полями (Title,User), т.к. оборачивает все content-шаблоны одним и тем же кодом — ничего специально прокидывать черезcontentне нужно. - Активный пункт рендерится как
<span aria-current="page" class="active">, а не<a>(не ссылка сама на себя), остальные — обычные ссылки. - CSS:
.nav [aria-current]— визуально выделенное состояние (цвет/подчёркивание/фон), уже в стиле остальной палитрыlayout.html.
B. Раздел настроек аккаунта (логин, пароль)
Сейчас нет способа сменить логин или пароль администратора после /setup — только пересоздание состояния БД. internal/store/admin.go содержит только CreateAdmin/GetAdmin, без Update*.
- Store:
UpdateAdmin(username, passwordHash string) error(один UPDATE поid = 1). - Web: новый маршрут
GET/POST /accountв authed-группе (web.go);handleAccountпоказывает форму (текущий логин, поля «новый логин», «текущий пароль», «новый пароль», «подтверждение нового пароля»);handlePostAccountпроверяет текущий пароль (bcrypt.CompareHashAndPassword, как вhandleLogin) перед применением изменений, затем вызываетUpdateAdmin. - Ссылка «Account» — в общей навигации (пункт A), рядом с
{{.User}}/«Sign out». - Security: та же lockout-защита, что у логина (rate-limit по неверному «текущему паролю», чтобы
/accountнельзя было использовать для брутфорса пароля обходным путём); при успешной смене пароля — инвалидировать все активные in-memory сессии, кроме текущей (заставить перелогиниться остальные при параллельном доступе — здесь один админ, так что риск минимален, но защищает от кражи старой cookie). - Шаблон:
account.htmlпо образцуlogin.html/setup.html(.card.narrow).
C. Backup/migration — отдельная страница, раздельные блоки
Сейчас на dashboard.html (строки 57–82) полный бэкап («Download full backup») и импорт домена («Import a domain») — два разных по смыслу и риску действия — свалены в одну карточку «Backup & migration» посреди страницы со списком доменов. Экспорт одного домена при этом уже (правильно) живёт отдельно, на самой странице домена (domain_detail.html:181).
- Новый маршрут
GET /backup(authed) с собственным пунктом в общей навигации (пункт A) — например «Backup». - На дашборде вместо целой карточки остаётся одна ссылка/карточка-указатель на
/backup(или пункт навигации целиком заменяет карточку — решить при реализации, что выглядит чище). - На странице
/backup— две отдельные карточки, не одна:- Full backup — тот же текст и форма (
POST /backup), что сейчас, без изменений в логике. - Import a domain — та же форма (
POST /domains/import), без изменений в логике.
- Full backup — тот же текст и форма (
- Домен-экспорт на
domain_detail.htmlне переносится — он уже привязан к конкретному домену и корректно расположен рядом с его данными; трогать не нужно. - Handler: новый
handleBackupPageрендеритbackup.html; существующиеhandleBackup/handleImportDomainне меняются (те же POST-маршруты).
Готово, когда: нав рендерится один раз из layout.html (не копипастой по content-шаблонам), поэтому присутствует на каждой странице панели, включая domain_detail.html/domain_delete.html (где нав-ссылок сейчас нет вовсе) и на всех будущих страницах этой и следующей фазы без отдельной правки каждой из них; текущий пункт визуально выделен и не дублируется как кликабельная ссылка на себя; /account позволяет сменить логин и/или пароль администратора с проверкой текущего пароля; после смены пароля старые сессии, кроме текущей, недействительны; /backup — отдельная страница с двумя визуально раздельными карточками (полный бэкап; импорт домена), дашборд больше не показывает эти формы напрямую; gofmt/vet/test/docker build зелёные.
Риски: смена логина не должна затронуть ничего, кроме таблицы admin (это учётная запись панели, не SASL-логины приложений — они хранятся отдельно в applications, см. Фазу 4); нужно убедиться, что rate-limit смены пароля не создаёт новый вектор lockout-DoS сверх уже описанного в п. A.1 открытых вопросов; вынос backup/import на отдельную страницу — чисто вёрстка, логика обработчиков /backup и /domains/import не меняется, так что риск регресса минимален.
D. Параметры подключения клиента на странице домена
Сейчас domain_detail.html показывает DKIM-запись и таблицу приложений (логин/режим адресов), но нигде на странице нет данных, нужных, чтобы настроить почтовый клиент/скрипт для отправки — сервер, порт, тип шифрования. Карточка «New application password» (строки 17–27) показывает только логин/пароль конкретного приложения — этого недостаточно без сервера/порта/шифрования рядом.
- Новая карточка «Sending server settings» на
domain_detail.html, рядом с карточкой DKIM (до или после — решить при вёрстке), с фиксированными для всего инстанса значениями:- Server:
{{.Hostname}}(то же значениеSELFPOST_HOSTNAME, которое уже используется для setup-ссылки и SASL realm — сервер уже хранит его вServer.cfg.Hostname, web.go:25; прокинуть в данные шаблонаdomain_detail, аналогично тому, как это уже сделано дляdashboard/setup). - Port 465 — SSL/TLS (implicit) — всегда доступен (primary listener, spec 5).
- Port 587 — STARTTLS (submission) — показывать строку только если включён
SUBMISSION_ENABLE(сейчас это чисто deploy-время env, .env.example:10; нужно завестиConfig.SubmissionEnabled boolв web.go, прокинуть изos.Getenv("SUBMISSION_ENABLE")в cmd/panel/main.go по аналогии сHostname). Если submission выключен — строку не показывать вовсе, а не показывать «недоступно», чтобы не путать. - Username: логин конкретного приложения — не общий для домена; сослаться на таблицу «Applications» ниже на той же странице, а не дублировать значение здесь (оно меняется per-application).
- Password: пояснение, что пароль отображается один раз при создании/регенерации приложения (уже описано в карточке
NewCred) — здесь не показываем.
- Server:
- Значения read-only (
.code, как остальные DNS-блоки), без форм — это справочная информация, не настройка.
Готово (доп. к критерию блока A/B/C): страница домена показывает сервер/порт(ы)/шифрование, необходимые для настройки клиента отправки, без необходимости смотреть в документацию или .env; порт 587 отображается только когда submission действительно включён на этом инстансе.
Риски: SubmissionEnabled — deploy-time флаг (docker-compose.yml), а не что-то, что панель может проверить рантаймом (например, слушает ли порт 587 реально) — если оператор выставил SUBMISSION_ENABLE=true в .env, но не перезапустил compose с проброшенным портом 587, страница покажет строку, которая не работает. Отметить это как известное ограничение (как и остальные deploy/env-производные показатели), не решать в рамках этой фазы усложнением (например, реальной проверкой прослушивания порта — это ближе к /status, не к странице домена).
E. Кнопка «Copy» на полях, которые переносятся во внешние сервисы
Значения из карточек .code сейчас нужно выделять мышью вручную — DKIM-запись (name/value),
логин/пароль нового приложения, будущие поля из пункта D (сервер) регулярно копируются в другой
интерфейс (DNS-панель регистратора, почтовый клиент, скрипт). Тривиально добавить копирование одной
кнопкой.
- Общий JS-хелпер в layout.html (без библиотек):
navigator.clipboard.writeText(text), вызываемый по клику маленькой кнопки/иконки рядом с каждым.code-элементом; кратковременная визуальная обратная связь (например, текст кнопки на 1–2 секунды меняется на «Copied»). - Разметка: небольшая обёртка
.code-row(код + кнопкаCopy) вместо голого.code, значение бралось изtextContentэлемента (не из отдельного JS-массива — тогда не рассинхронизируется с тем, что реально показано). - Применить везде, где сейчас есть
.codeдля значений, которые логично куда-то переносить: DKIMRecord.Name/Record.Value(domain_detail.html:35, domain_detail.html:41),NewCred.Login/NewCred.Password(domain_detail.html:23, domain_detail.html:25), поля карточки «Sending server settings» из пункта D. Таблица приложений (td.codeс логином) — тоже кандидат, но менее приоритетно (короткое значение, легко выделить руками). - Секьюрити:
navigator.clipboard.writeTextтребует secure context (HTTPS илиlocalhost) — в проде это всегда так (панель форсирует HTTPS), в dev-режиме сPANEL_COOKIE_SECURE=falseпо голому HTTP кнопка может тихо не сработать в некоторых браузерах. Не городитьdocument.execCommand('copy')-fallback ради dev-режима — задокументировать как известное ограничение и/или обернуть вtry/catch, чтобы отсутствие Clipboard API не ломало страницу, а просто оставляло текст доступным для ручного выделения.
Готово: у DKIM-записи, нового пароля приложения и параметров подключения (сервер) есть кнопка «Copy», по клику значение оказывается в буфере обмена и показывается краткое подтверждение.
Риски: нет побочных эффектов на бэкенд — чистая клиентская правка; единственный нюанс — secure-context ограничение Clipboard API в dev, описанное выше.
F. Скрывать поле адресов в режиме «Any address»
Обе формы с выбором режима адресов — «Add an application» (domain_detail.html:163–171) и «Edit mode» на существующем приложении (domain_detail.html:72–79) — всегда показывают textarea «Addresses», даже когда выбран Any address of the domain (wildcard), где это поле не используется и просто игнорируется сервером. Из-за этого неочевидно, что поле относится только к режиму «List».
- Чистый клиентский JS (без изменений на сервере — валидация
mode/addressesвinternal/appне трогается, т.к. в wildcard-режиме бэкенд и так игнорирует содержимое textarea): наchangeу<select name="mode">показывать/прятать соответствующий<label>+<textarea addresses>черезhidden/display:none, в зависимости от того, выбран ли$.Wildcardили$.List. - Начальное состояние при загрузке страницы — тоже должно учитывать текущее значение
select(важно для формы «Edit mode», гдеselectуже может быть предзаполнен значениемList, а не только для формы добавления, где по умолчаниюWildcard). - Реализовать один небольшой переиспользуемый скрипт (или
<script>внизуdomain_detail.html), т.к. на странице несколько экземпляров этой пары select+textarea (форма добавления + одна на каждое существующее приложение в таблице).
Готово: при выборе «Any address of the domain» поле «Addresses» скрывается (и в форме добавления, и в «Edit mode» для существующих приложений); при выборе «Specific addresses (list)» — снова показывается с уже введённым значением, если было.
Риски: нет — чисто клиентское поведение, серверная валидация уже игнорирует addresses для wildcard-режима, так что скрытие поля ничего не меняет по сути обработки формы.
Модель: Sonnet (UI + рутинный CRUD, не риск-критичный тракт доставки).
Зависимости: не блокируется другими фазами; вводит общий nav-partial и Active-механизм, которым позже пользуется Фаза 13 (добавляет туда пункт «Status» и переносит «Domains» на /domains) — предпочтительно делать Фазу 12 первой.
Фаза 13 (v1.x) — Страница статуса сервиса + DNS-проверка доменов
Статус: запланирована, не начата.
Цель: дать оператору один взгляд на «сервис жив и почта не будет зарубаться из-за DNS» — два независимых экрана: здоровье процесса (/status) и корректность DNS для каждого отправляющего домена (встроено в существующую страницу домена). Выходит за рамки ТЗ v1.0 (не было в разделе 7.2), согласовано с пользователем как отдельная фаза. /status также становится стартовой страницей панели (см. пункт C) — первое, что видит админ после логина, вместо списка доменов — и получает кнопку «Reload», перенесённую с дашборда с понятным пояснением назначения (см. пункт D).
A. /status — здоровье сервера (только сервер, без доменов)
- Процессы supervisord —
supervisorctl status(fixed-argv, без shell, как остальные exec-вызовы проекта), статус opendkim/postfix/panel/cert-reload/logrotate. - Очередь Postfix — переиспользовать
internal/postfix.Queue(). - TLS-сертификат — распарсить
x509.NotAfterизTLS_CERT_FILE, предупреждение при приближении срока истечения. - Milter-сокеты (
os.Statнаopendkim.sock/journal.sock): отсутствиеopendkim.sock— ошибка (приdefault_action=tempfailпочта не уходит, см. progress.md:132); отсутствиеjournal.sock— предупреждение (fail-open, почта уходит, но Send Log не пишется). - PTR / прямое-обратное соответствие (FCrDNS) — ключевая проверка для доставляемости:
- резолвим A/AAAA
SELFPOST_HOSTNAME→ IP сервера; - резолвим PTR этого IP → имя;
- сравниваем PTR-имя с
SELFPOST_HOSTNAME. - OK — совпадают; ошибка — PTR отсутствует или не совпадает (многие принимающие сервера отклоняют/спамят почту без корректного PTR).
- Резолвленный IP сервера переиспользуется в пункте B.2 (SPF) — отдельный env для IP не нужен.
- резолвим A/AAAA
Дешёвые проверки (1–4) — HTMX-polling как на остальных экранах мониторинга (~5с). PTR/hostname-lookup чуть дороже сети — кэш с TTL (например 1 мин) или отдельная кнопка «Recheck», не завязывать на 5-секундный polling.
B. DNS-статус домена (на странице domain_detail.html, рядом с существующей DKIM TXT-записью)
- DKIM — резолвим
<selector>._domainkey.<domain>TXT, сравниваем с реальным ключом OpenDKIM (переиспользовать логику генерации записи из dkim.go). - SPF (поверхностная проверка, по решению пользователя) — резолвим TXT домена, ищем запись с
v=spf1. Если найдена — проверяем только присутствие механизма, покрывающего IP сервера (ip4:/ip6:буквально, либоa/mxбез аргумента, резолвящийся в IP сервера из A.5). Без рекурсии поinclude, без полной RFC 7208-оценки pass/fail/softfail — осознанное упрощение (без новых зависимостей). - DMARC — TXT
_dmarc.<domain>, наличие + политика (none/quarantine/reject). - Статусы по каждому пункту: не найдено / найдено, но не покрывает наш сервис / корректно. Кэш на домен (TTL ~5–10 мин) + кнопка «Recheck» (без autopolling — DNS дороже, чем локальные проверки блока A).
C. /status — стартовая страница панели
Сейчас корневой маршрут GET /{$} (handleDashboard, web.go:109) отдаёт список
доменов — это же первая страница после логина. По решению пользователя стартовой страницей должна
быть страница статуса, а не список доменов.
- Список доменов (карточки «Add a sending domain» + «Domains» из dashboard.html) переезжает на новый маршрут
GET /domains(тот жеhandleDashboard, просто перевешенный на другой путь;POST /domainsдля добавления домена остаётся как есть — коллизий сGET /domainsнет, разные методы). - Корневой
GET /{$}начинает рендерить/status(страницу из блока A) — либо редиректом/{$}→/status, либо status-хендлер напрямую вешается и на/, и на/status(без редиректа, чуть дешевле). Редирект проще и не создаёт двух путей для одного контента — предпочтительный вариант. - Логин:
handleLoginпосле успешной аутентификации сейчас редиректит на/— поведение не меняется (просто/теперь означает статус, а не домены), правки вhandlers_auth.goне требуется. - Навигация (Фаза 12.A): пункт «Status» становится первым в общем
nav-partial и получаетActive == "status"; пункт «Domains» указывает на/domainsвместо/. Это расширяет списокActive-значений, заведённый в Фазе 12, а не меняет его архитектуру. - Везде, где по коду сейчас зашит редирект/ссылка на
/как «страница доменов» (например,<a class="back" href="/">← Domains</a>вqueue.html,sendlog.html,logtail.html,domain_detail.html,domain_delete.html), ссылку нужно поменять на/domains.
Готово (доп. к критерию блока A/B): GET / открывает /status; список доменов доступен по GET /domains; все ссылки «← Domains» и пункт навигации «Domains» ведут на /domains; логин после успешной аутентификации попадает на страницу статуса.
Риски: нужно пройтись по всем захардкоженным href="/" в шаблонах (см. список выше) — пропущенная ссылка тихо ведёт на статус вместо доменов, а не ломается явно, поэтому стоит грепнуть href="/" после реализации и проверить каждое совпадение.
D. Кнопка «Reload» — назначение непонятно, присутствует непоследовательно
handleReload (handlers_domains.go:119) пересобирает конфигурацию OpenDKIM и Postfix-карту отправителей из БД и перечитывает оба демона (spec 7.2.12) — это ручной drift-recovery («на всякий случай пересобери конфиг с нуля»), а не элемент навигации. Сейчас кнопка:
- есть только на дашборде (dashboard.html:9), в топбаре вперемешку с навигационными ссылками — на
queue/sendlog/logtail/domain_detailеё нет; - подписана просто «Reload» без единого слова, что она делает;
- после нажатия даёт флеш «Configuration reloaded.» — тоже без объяснения, что было пересобрано и зачем это могло понадобиться.
Итог: пользователю (да и админу, впервые видящему панель) неясно, что это за действие, когда его стоит нажимать, и почему оно недоступно с других страниц.
Решение: перенести Reload на /status (блок A) — тематически это ровно то же самое действие, что и остальной блок здоровья сервера (проверка/восстановление рабочего состояния демонов), и после Фазы 13 /status и так становится стартовой страницей, так что кнопка не теряется, а оказывается на самом заметном месте.
- Убрать кнопку/форму
POST /reloadиз dashboard.html и топбара. - Добавить на
/statusкарточку «Configuration» (или секцию рядом с блоком A.1–A.4) с формойPOST /reloadи явным пояснением: «Regenerates the OpenDKIM and Postfix configuration from the database and reloads both daemons. Use this if you edited files manually, restored a backup, or the config looks out of sync with the domain/application list below — it does not affect the mail queue or TLS.» - Редирект
handleReload— сейчас"/?reloaded=1"(handlers_domains.go:130); после переноса меняется на"/status?reloaded=1", и флеш-сообщение обрабатывается на странице статуса (dashboardFlashдляreloadedубирается — переносится в статус-хендлер,deleted/остальные флеши дашборда остаются на месте). - Логика
handleReload(самResyncобоих компонентов) не меняется — это чисто перенос UI и текста, не изменение поведения.
Готово (доп. к критерию блока A/B/C): кнопка Reload есть только на /status, с текстом, объясняющим, что именно она пересобирает и когда это нужно; на дашборде/остальных страницах кнопки/флеша про reload больше нет.
Риски: нет — чисто перенос существующего, уже проверенного действия; главное — не потерять флеш-сообщение при переносе редиректа.
Архитектура
- Новый пакет
internal/dnscheck:ServerIP(hostname)(A/AAAA lookup),PTRCheck(hostname, ip),DKIMCheck(domain, selector, expectedKey),SPFCheck(domain, serverIP),DMARCCheck(domain)— все с таймаутом на DNS-запрос (~5с), черезnet.DefaultResolver/контекст. - Кэш результатов в памяти (мьютекс, TTL), без изменений схемы БД/миграций.
- Веб:
GET /status+GET /status/fragment(быстрый блок A, для polling); секция DNS-статуса наdomain_detail.html+POST /domains/{id}/dns-recheck(форс, обход кэша). - Безопасность: ничего нового по ТЗ 7.6 не добавляет — DNS/exec-вызовы только за авторизованным админом, без пользовательского ввода в exec (доменные имена уже провалидированы при добавлении домена), таймауты на все сетевые вызовы (защита от зависания страницы).
Готово, когда: /status показывает живой статус всех пяти пунктов блока A и обновляется polling'ом и является стартовой страницей (GET /); список доменов доступен на GET /domains; на странице домена отображается статус DKIM/SPF/DMARC с кнопкой Recheck; PTR-проверка корректно ловит и совпадение, и несовпадение (проверено на реальном домене selfpost.example.com, у которого PTR уже настроен, — см. [selfpost-prod-deployment.md]); кнопка «Reload» присутствует только на /status, с пояснением назначения; gofmt/vet/test/docker build зелёные.
Риски: DNS-резолверы могут быть медленными/недоступными — все проверки таймаутят и не блокируют остальной UI (страница рендерится с "неизвестно/timeout", а не висит). Ложные срабатывания SPF-эвристики (например, домен покрывает IP сервера через include: стороннего сервиса, который в свою очередь резолвится в IP сервера) — задокументировать как известное ограничение поверхностной проверки. Плюс риск блока C — не пропустить захардкоженную ссылку на /.
Модель: Sonnet (UI + рутинные DNS-lookup, не риск-критичный тракт доставки/безопасности).
Зависимости: использует общий nav-partial и Active-механизм навигации, вводимые Фазой 12 — реализовывать после неё (или расширить тот же PR); использует уже реализованные internal/postfix.Queue(), DKIM-логику домена, паттерны HTMX-фрагментов из Фазы 7.
Опциональные фазы — целевой релиз 2.x.x (вне базового объёма v1.0)
Эти фазы не входят в линейный базис 0→11 и не являются частью поставки v1.0 (v1.x — только исходящий релей). Они отнесены к релизной линии 2.x.x и добавлены в дорожную карту как согласуемые расширения. Реализация — только после явного согласования (ТЗ 12.6): ТЗ v1.0 раздел 3 явно исключает приём входящей почты из объёма, поэтому включение этой функциональности — сознательное расширение границ проекта (major-релиз 2.0), а не доработка по своей инициативе. Внесение в план фиксирует намерение и дизайн; кодирование начинается отдельным решением.
Фаза O1 (→ 2.x.x) — Входящий релей (backup-MX / пересылка) — опция/плагин
Цель: возможность принимать почту на порт 25 для явно настроенных доменов и пересылать её на заданный вышестоящий backend (роль backup-MX / relay-forwarder), как выключаемый по умолчанию модуль, не затрагивающий поведение и поверхность атаки базового исходящего релея.
Зачем это нужно (сценарии):
- Backup-MX — принять почту, когда основной почтовый сервер домена временно недоступен, и передать её, когда он вернётся.
- Фронт для сервера без внешнего IP — у оператора есть свой почтовый сервер, который по каким-то причинам сам не может принимать почту из интернета (нет статического/внешнего IP, за NAT, серый адрес, закрытый порт 25 на входящую и т.п.). SelfPost с публичным IP и корректным PTR выступает публичным входным узлом для домена (MX указывает на него) и пересылает почту на этот внутренний/недоступный извне сервер.
Граница объёма (критично — что это НЕ):
- ЭТО: приём на 25 для доменов из явного списка + пересылка (relay/forward) на upstream (
relay_domains+transport_maps+relay_recipient_maps). Postfix здесь — чистый пересыльщик, без локальной доставки. - ЭТО НЕ (остаётся out of scope, ТЗ 3): локальная доставка в почтовые ящики, IMAP/POP3, webmail, Dovecot. Никаких mailbox'ов. SelfPost также не реализует и не тянет в свой образ движок антиспама/антивируса (rspamd/ClamAV) — но, в отличие от прежней формулировки, и не перекладывает фильтрацию на backend (см. блок «Антиспам» ниже): предоставляет точку подключения внешнего фильтра.
Почему как опция/плагин:
- Приём на порт 25 меняет модель угроз (open relay для входящей, backscatter, spam-ingress). Поэтому по умолчанию выключено флагом env
INBOUND_RELAY_ENABLE=false; включение — осознанный шаг оператора. - Изоляция: отдельные таблицы SQLite, отдельные хендлеры/страницы панели, отдельная ветка генерации конфига. При выключенном флаге входной listener, таблицы и UI отсутствуют — базовый исходящий тракт байт-в-байт неизменен.
Что делать:
- Env-флаг
INBOUND_RELAY_ENABLE(default false); приtrue— генерировать входной сервис и его конфиг из состояния панели тем же путём, что остальной конфиг (postfix-config.sh). master.cf: входнойsmtp inetна 25 для приёма из интернета (сейчас 25 используется только на исходящую доставку). Отдельный от 465/587: на 25 не предлагается SASL и не разрешается отправка наружу — только приём дляrelay_domains.- Анти-open-relay для входящей (обязательно):
smtpd_relay_restrictions/smtpd_recipient_restrictionsвходного smtpd принимают почту только для доменов изrelay_domainsи только для известных получателей (relay_recipient_maps); всё прочее —reject_unauth_destination/reject_unlisted_recipient. Открытый релей и приём «для кого угодно» невозможны. - Backscatter: предпочтительно знать валидных получателей (reject unknown recipient на этапе RCPT), чтобы не порождать bounce на несуществующие адреса.
- Панель управляет: список входящих доменов; для каждого — upstream destination (
host:port, транспорт), опциональный список валидных получателей, опциональный TLS к upstream. Строгая валидация домена/хоста/порта (whitelist), injection-safe запись map-файлов (какsender_login_mapsв Фазе 4),os/execбез shell (ТЗ 7.6.2–4). - Милтеры: OpenDKIM на входящем тракте не нужен (чужую входящую не подписываем). journal-milter опционально переиспользовать для журнала входящих (доп. работа) либо на первом этапе оставить входящий без него; поведение fail-open сохраняется.
- Rate-limit/размер: грубый лимит по client IP (
anvil, как L1) иmessage_size_limitна входном smtpd.
Антиспам (важная, но опциональная возможность). Это ценная опция, но она не обязательна: часть операторов вполне устроит слепая пересылка без фильтрации — например, когда backend сам умеет фильтровать по содержимому, стоит доверенный upstream, или объём/риск невелик. Поэтому антиспам-хук по умолчанию выключен (пустой INBOUND_ANTISPAM_MILTER), и входящий релей полностью работоспособен без него. Важно другое — где фильтрация возможна технически: при «слепом» relay целевой backend видит подключающимся IP адрес SelfPost, а не исходного отправителя, поэтому на backend'е ломается всё, что завязано на origin IP (DNSBL/репутация проверяются против IP SelfPost, SPF даёт fail — SelfPost не входит в SPF домена-отправителя). Единственная точка, где ещё виден настоящий client IP — входной хоп на SelfPost; поэтому тем, кому фильтрация нужна, она должна быть подключаема именно здесь, а не переложена на backend, который эту информацию уже потерял. Дизайн подключения:
- Движок антиспама — отдельный опциональный контейнер (rspamd и т.п.), который оператор запускает только если нужна эта опция (тот же принцип, что reverse-proxy — отдельный контейнер вне образа SelfPost). SelfPost его не содержит и не запускает — образ и принцип «один контейнер, три процесса» неизменны, ТЗ 3 не нарушается (SelfPost не реализует антиспам).
- SelfPost предоставляет точку подключения: milter-хук на входном smtpd. Адрес движка задаётся env (например,
INBOUND_ANTISPAM_MILTER=inet:antispam:11332, пусто → хук выключен) и добавляется вsmtpd_miltersтолько входного тракта (не на 465/587). Postfix передаёт milter'у настоящий client IP/HELO/PTR — фильтр видит истинный origin.milter_default_actionдля этого milter'а — конфигурируемый (fail-open vs tempfail); дефолт определить при реализации. - Нативный backstop без зависимостей: на том же входном хопе доступны средства Postfix по origin IP —
reject_rbl_client(DNSBL), проверки HELO/PTR — работают даже без внешнего контейнера. Плюс сохранение аутентификации для downstream через ARC/Receivedтам, где часть фильтрации всё же остаётся на backend. - docker-compose: задокументировать опциональный фрагмент antispam-сайдкара (как альтернативные фрагменты reverse-proxy) — контейнер поднимается вместе со стеком только при включённой опции.
- Персистентность: новые таблицы и map-файлы под
/data— попадают в полный бэкап автоматически (Фаза 9). Экспорт/импорт домена можно расширить входящей конфигурацией — опционально, пометить. - DNS-документация: для входящего домена нужна
MX-запись, указывающая на сервер (в отличие от исходящего, где MX не требуется) — отразить в разделе DNS README.
Безопасность (ТЗ 7.6 распространяется полностью): валидация ввода на сервере, экранирование записи в конфиги, exec без интерполяции, никакого open relay, защита от backscatter.
Готово, когда: при INBOUND_RELAY_ENABLE=true и настроенном домене письмо на порт 25 для этого домена пересылается на заданный upstream; почта для ненастроенных доменов/получателей отклоняется (не open relay, не backscatter); при заданном INBOUND_ANTISPAM_MILTER входящая проходит через внешний фильтр с настоящим origin IP (проверено сайдкар-контейнером), при пустом — хук не мешает; при INBOUND_RELAY_ENABLE=false — входной порт/таблицы/UI отсутствуют, базовый исходящий релей неизменён; build/vet/test/образ зелёные.
Риски: open relay/backscatter (снимается relay_domains + relay_recipient_maps + reject_unauth_destination); потеря origin IP для фильтрации на backend'е при пересылке (снимается milter-хуком антиспама + нативным DNSBL на входном хопе, где origin IP ещё виден); порт 25 на приём расширяет поверхность атаки (по умолчанию выключено). Модель: Opus (инфра/безопасность, риск open relay). Внешняя зависимость деплоя: опциональный antispam-контейнер — вне образа SelfPost, поднимается оператором при включении опции.
Зависимости: не является частью v1.0, зависит только от готового исходящего тракта (уже реализован) и требует отдельного согласования (ТЗ 12.6, расширение за пределы раздела 3) до кодирования.