Phase 13. Two new packages and one new screen. internal/health owns the shared status vocabulary (ok/warn/error/unknown) and the local checks: supervisord's process table, TLS certificate expiry and the two milter sockets. Each check reports a problem as a status rather than an error, so one broken component costs a line and not the page. internal/dnscheck does the read-only lookups: forward-confirmed reverse DNS for SELFPOST_HOSTNAME, and per-domain DKIM (compared against the key this server actually signs with), SPF and DMARC. Every check is bounded by a timeout and cached, and the resolver sits behind an interface so the tests drive every branch without touching the network. The SPF check is deliberately shallow: it looks for a mechanism literally covering the server's address and does not follow include:/redirect=, so a record that authorises us through an include is reported as "cannot tell" rather than as a failure. /status renders both, with the local checks in an HTMX-polled fragment and the DNS lookups behind a Re-check button, and becomes the panel's landing page: / now redirects there and the domain list lives at /domains. The Reload button moves onto /status, where it reads as what it is — a drift-recovery for the daemons — with text explaining what it regenerates. A template test fails on any remaining href="/" so a stale link cannot silently land on the wrong screen. Also fixes a defect this made visible: the panel could never read the mail queue in the documented deployment. postqueue relies on its setgid-postdrop bit, which the compose file's no-new-privileges disables, so the Queue screen always said "Could not read the mail queue" — including in the released 1.0.0 image. The panel user is now a real member of postdrop, which needs no setgid transition. Verified in a container on the dev server against real DNS: PTR matching (selfpost.mixfed.ru) and not matching (mixfed.ru), DKIM absent and mismatched, SPF absent and via include:, DMARC p=quarantine/p=reject/absent, and a resolver timeout degrading to "unknown" without hanging the page. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
27 KiB
План реализации: SelfPost
Статус: базовый линейный план (фазы 0→11, v1.0) выполнен и принят — см. progress.md (текущее состояние) и CHANGELOG.md (история релизов). Он здесь не повторяется.
Ниже остаётся только то, что ещё не сделано: открытые вопросы для согласования и опциональная линия 2.x.x.
Фаза 12 (UI/UX: общий nav-partial, /account, /backup, параметры
подключения, кнопки Copy, скрытие поля адресов) выполнена — детали в
CHANGELOG.md, здесь не повторяется.
Фаза 13 (страница /status, DNS-статус домена, перенос списка доменов на
/domains, перенос кнопки Reload) выполнена — детали в
CHANGELOG.md, здесь не повторяется.
Основа: 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. Общий принцип: всю сложность стараемся держать в сервисе, а конфигурация reverse-proxy должна оставаться максимально простой, чтобы её было сложно сломать неудачной правкой. Реализация — см. Фазу 14.A. - 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) — альтернатива, уже реализована в коде (internal/web/setup.goannounce/token file). Решено: базовый вариант объявления — stdout (по ТЗ), код не меняется. Остаётся только осветить это в пользовательской документации и указать на уже существующий файл/data/setup-tokenкак более защищённую альтернативу для тех, у кого логи контейнера уезжают в централизованный агрегатор. Реализация — см. Фазу 14.B. - Нет 2FA / смены пароля админа / нескольких админов из UI. ТЗ этого не требует (один админ, secret-link). Смена пароля сейчас — только пересоздание состояния. Решено и реализовано (Фаза 12): добавлен раздел настроек аккаунта
/account(логин + пароль, с проверкой текущего пароля и инвалидацией остальных сессий). 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 ./...). - Нет интеграционного/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). Здесь перечислены лишь как напоминание, что это сознательно отложенный объём, а не забытый.
Фаза 14 (v1.x) — Реализация принятых решений по hardening (раздел A)
Статус: запланирована, не начата.
Цель: довести до кода два уже принятых, но пока не реализованных решения из раздела A. Безопасность (остальные пункты раздела A либо уже реализованы — п.1, либо являются открытыми вопросами без решения — п.3/4, либо уже реализованы в Фазе 12 — п.6).
A. Security-заголовки ответа (пункт A.2)
Добавить в панель единый middleware, оборачивающий все ответы (кроме, возможно, уже застриманных HTMX-фрагментов, где это не мешает), выставляющий:
Strict-Transport-Security(только когдаPANEL_COOKIE_SECURE/TLS включён — по аналогии с__Host-/Secure-логикой, HSTS на голом HTTP в dev-режиме бессмысленен и может быть вреден);X-Content-Type-Options: nosniff;X-Frame-Options: DENY(илиContent-Security-Policy: frame-ancestors 'none'— эквивалент, дублировать не обязательно);Referrer-Policy: same-origin(илиno-referrer— выбрать более строгий, поведение панели не зависит от referrer);Content-Security-Policy— минимальная политика под текущий фронтенд (inline-стили/скрипты, HTMX): нужно свериться с шаблонами (internal/web/templates) на предмет<script>/style="..."/onclickперед тем, как писать политику, чтобы не сломать текущий UI.
Готово, когда: все ответы панели содержат перечисленные заголовки (кроме HSTS в dev/non-secure режиме); UI (включая HTMX-фрагменты и polling) продолжает работать без консольных ошибок CSP; gofmt/vet/test зелёные.
Риски: слишком строгий CSP может тихо сломать inline-скрипты/стили в существующих шаблонах — проверить вручную в браузере (открыть каждую страницу, проверить консоль на CSP-violations) после реализации, а не полагаться только на юнит-тесты.
Модель: Sonnet (небольшой, хорошо специфицированный мидлварь).
B. Документация про /data/setup-token (пункт A.5)
Код уже пишет setup-токен в /data/setup-token (0600) в дополнение к stdout
(internal/web/setup.go) — это не требует изменений.
Остаётся только документационная задача:
- В README (раздел про первый запуск/setup-ссылку) добавить абзац: по умолчанию ссылка печатается в stdout контейнера (spec 7.6.1), но токен также лежит в файле
/data/setup-tokenвнутри смонтированного/data— для тех, у кого логи контейнера уезжают в центральный агрегатор и не хочется, чтобы токен там оседал на 10 минут, безопаснее прочитать файл (docker exec/ примонтированный volume) вместо просмотра логов.
Готово, когда: README содержит этот абзац рядом с описанием setup-ссылки.
Риски: нет — чисто документация, код не меняется.
Модель: Sonnet (документация).
Зависимости: нет, можно делать независимо от Фаз 12/13.
Опциональные фазы — целевой релиз 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) до кодирования.