Files
selfpost/docs/implementation-plan.md
T
mix d82d9736bd docs: plan Phase 12 - service status page + domain DNS checks
Adds a new agreed-upon phase covering /status (supervisord processes,
Postfix queue, TLS cert expiry, milter sockets, PTR/FCrDNS check) and
per-domain DNS correctness status (DKIM/SPF-heuristic/DMARC) on the
domain page. Not part of v1.0 spec scope; scoped and agreed with the
user before implementation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 23:12:56 +03:00

29 KiB
Raw Blame History

План реализации: 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

  1. Rate-limit за обратным прокси кеится по RemoteAddr (internal/web/web.go clientIP). За дефолтным Apache это адрес прокси → лимитеры логина и /setup фактически глобальны. Осознанный выбор: не парсить X-Forwarded-For (иначе тривиально обходится подделкой заголовка). По ТЗ 7.6.1 реальная защита setup — 128-бит энтропии токена, rate-limit там defense-in-depth. Побочный эффект: атакующий может исчерпать общий bucket логина (10 попыток/15 мин) и на 15 минут заблокировать вход легитимному админу (lockout-DoS). Варианты: (а) оставить как есть (просто, по ТЗ достаточно); (б) парсить XFF только от доверенного прокси (TRUSTED_PROXY_CIDR) → настоящий per-client лимит; (в) вместо жёсткой блокировки — экспоненциальная задержка ответа, чтобы brute-force тормозился, но легитимный вход не блокировался.
  2. Нет 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'/строгий CSP default-src 'self'), т.к. панель знает свою модель контента, а прокси у всех разный.
  3. CSRF — только SameSite=Lax, без токенов. Достаточно для современных браузеров (все мутации — POST, все GET read-only), но не защищает при downgrade до старого браузера/особых прокси и не даёт защиты на уровне «per-request». Вопрос: считать SameSite=Lax достаточным для single-admin панели (моя рекомендация — да) или добавить double-submit CSRF-токен.
  4. Cookie без префикса __Host-. Сейчас selfpost_session (Secure/HttpOnly/SameSite=Lax/Path=/). Префикс __Host- дал бы браузерный гарант «только HTTPS, только этот хост, без Domain». Мелочь, но бесплатная. Вопрос: переименовать (учесть dev-режим PANEL_COOKIE_SECURE=false__Host- требует Secure, т.е. только когда secure включён).
  5. Setup-ссылка печатается в stdout контейнера. По ТЗ (7.6.1) — так и задумано, но если логи контейнера уезжают в агрегатор, токен там осядет на 10 минут. Файл /data/setup-token (0600) — альтернатива. Оставить как есть (по ТЗ), но отметить в README, что для сред с централизованными логами предпочтителен файл.
  6. Нет 2FA / смены пароля админа / нескольких админов из UI. ТЗ этого не требует (один админ, secret-link). Смена пароля сейчас — только пересоздание состояния. Вопрос: нужен ли эндпоинт «сменить пароль администратора» в v1.x (разумное мелкое добавление) — 2FA и мульти-админ явно 2.x/вне объёма.

B. Надёжность и эксплуатация

  1. Сессии только в памяти — рестарт/редеплой разлогинивает админа (по ТЗ 9 допустимо). Плюс: absolute TTL 12ч без отдельного idle-timeout и без ротации токена при логине (session-fixation здесь неактуален, т.к. токен выдаётся только после аутентификации). Оставить; отметить поведение в README.
  2. Окно потери строк мониторингового лога при ротации (copytruncate, Фаза 10) — несколько строк mail.log могут потеряться в момент ротации. Приемлемо для мониторинга; зафиксировать как известное свойство.
  3. Поведение при незаданном SELFPOST_HOSTNAME — realm SASL и хост setup-ссылки падают в localhost. Для реального деплоя hostname обязателен. Вопрос: делать ли фатальную проверку «hostname обязателен» на старте (сейчас — мягкий fallback) — предложение: предупреждать громко в лог, но не падать.

C. CI и тесты

  1. CI не гоняет go test. .github/workflows/release.yml на теге только собирает и пушит образ; go vet выполняется внутри Dockerfile-сборки, но юнит-тесты в CI не запускаются — вся тестовая проверка идёт вручную на dev-сервере. Рекомендация: добавить обычный workflow на push/PR (go vet + go test ./... + gofmt -l), чтобы регресс ловился до тега релиза. Небольшая работа, заметно повышает доверие к «зелёному» релизу.
  2. Нет интеграционного/e2e-теста в CI. Контейнерные e2e каждой фазы прогонялись вручную и задокументированы в git-истории, но не автоматизированы. Для v1.0 — вероятно, оставить ручными; для долгой поддержки — кандидат на smoke-тест (поднять контейнер, setup→login→add domain→auth SMTP) в CI.

D. Указатель на объём 2.x

  1. Входящий релей и pluggable-антиспам вынесены в опциональные фазы O1+ ниже (линия 2.x.x, вне v1.0, только по согласованию — ТЗ 12.6). Здесь перечислены лишь как напоминание, что это сознательно отложенный объём, а не забытый.

Фаза 12 (v1.x) — Страница статуса сервиса + DNS-проверка доменов

Статус: запланирована, не начата.

Цель: дать оператору один взгляд на «сервис жив и почта не будет зарубаться из-за DNS» — два независимых экрана: здоровье процесса (/status) и корректность DNS для каждого отправляющего домена (встроено в существующую страницу домена). Выходит за рамки ТЗ v1.0 (не было в разделе 7.2), согласовано с пользователем как отдельная фаза.

A. /status — здоровье сервера (только сервер, без доменов)

  1. Процессы supervisordsupervisorctl status (fixed-argv, без shell, как остальные exec-вызовы проекта), статус opendkim/postfix/panel/cert-reload/logrotate.
  2. Очередь Postfix — переиспользовать internal/postfix.Queue().
  3. TLS-сертификат — распарсить x509.NotAfter из TLS_CERT_FILE, предупреждение при приближении срока истечения.
  4. Milter-сокеты (os.Stat на opendkim.sock/journal.sock): отсутствие opendkim.sockошибка (при default_action=tempfail почта не уходит, см. progress.md:132); отсутствие journal.sockпредупреждение (fail-open, почта уходит, но Send Log не пишется).
  5. PTR / прямое-обратное соответствие (FCrDNS) — ключевая проверка для доставляемости:
    • резолвим A/AAAA SELFPOST_HOSTNAME → IP сервера;
    • резолвим PTR этого IP → имя;
    • сравниваем PTR-имя с SELFPOST_HOSTNAME.
    • OK — совпадают; ошибка — PTR отсутствует или не совпадает (многие принимающие сервера отклоняют/спамят почту без корректного PTR).
    • Резолвленный IP сервера переиспользуется в пункте B.2 (SPF) — отдельный env для IP не нужен.

Дешёвые проверки (1–4) — HTMX-polling как на остальных экранах мониторинга (~5с). PTR/hostname-lookup чуть дороже сети — кэш с TTL (например 1 мин) или отдельная кнопка «Recheck», не завязывать на 5-секундный polling.

B. DNS-статус домена (на странице domain_detail.html, рядом с существующей DKIM TXT-записью)

  1. DKIM — резолвим <selector>._domainkey.<domain> TXT, сравниваем с реальным ключом OpenDKIM (переиспользовать логику генерации записи из dkim.go).
  2. SPF (поверхностная проверка, по решению пользователя) — резолвим TXT домена, ищем запись с v=spf1. Если найдена — проверяем только присутствие механизма, покрывающего IP сервера (ip4:/ip6: буквально, либо a/mx без аргумента, резолвящийся в IP сервера из A.5). Без рекурсии по include, без полной RFC 7208-оценки pass/fail/softfail — осознанное упрощение (без новых зависимостей).
  3. DMARC — TXT _dmarc.<domain>, наличие + политика (none/quarantine/reject).
  4. Статусы по каждому пункту: не найдено / найдено, но не покрывает наш сервис / корректно. Кэш на домен (TTL ~5–10 мин) + кнопка «Recheck» (без autopolling — DNS дороже, чем локальные проверки блока A).

Архитектура

  • Новый пакет 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'ом; на странице домена отображается статус DKIM/SPF/DMARC с кнопкой Recheck; PTR-проверка корректно ловит и совпадение, и несовпадение (проверено на реальном домене selfpost.example.com, у которого PTR уже настроен, — см. [selfpost-prod-deployment.md]); gofmt/vet/test/docker build зелёные.

Риски: DNS-резолверы могут быть медленными/недоступными — все проверки таймаутят и не блокируют остальной UI (страница рендерится с "неизвестно/timeout", а не висит). Ложные срабатывания SPF-эвристики (например, домен покрывает IP сервера через include: стороннего сервиса, который в свою очередь резолвится в IP сервера) — задокументировать как известное ограничение поверхностной проверки.

Модель: Sonnet (UI + рутинные DNS-lookup, не риск-критичный тракт доставки/безопасности).

Зависимости: не блокирует и не блокируется существующими фазами; использует уже реализованные 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.24).
  • Милтеры: 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) до кодирования.