copytruncate loses log records twice per rotation: everything written since the tailer's last poll (kept in mail.log.1, but skipped because the descriptor points at the truncated inode) and whatever lands between the copy and the truncate (gone for good). Those records carry the final delivery statuses the send log is reconciled from, so a dropped line means a row stuck in "queued" — not just a gap in the monitoring view, as the item previously assumed. Decision recorded, implementation deferred to its own step. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
27 KiB
План реализации: SelfPost
Статус: выполненные фазы здесь не описываются — текущее состояние в
progress.md, история сделанного в CHANGELOG.md
и git log. Ниже остаётся только то, что ещё не сделано: открытые вопросы
для согласования и опциональная линия 2.x.x.
Основа: specification.md v1.0.
Открытые вопросы — требует внимания и обсуждения (перед фиксацией v1.0)
Ниже — то, что выходит за букву ТЗ, но заслуживает решения перед тем, как считать v1.0 «финальным». Ничего из этого не является дефектом соответствия; это осознанные компромиссы и потенциальные улучшения. Каждый пункт — решение «делаем в v1.x / откладываем в 2.x / оставляем как есть», принимается пользователем.
A. Безопасность — принятые риски
Hardening сверх обязательного 7.6 закрыт. Здесь остаётся только то, что закрыто сознательно не было, чтобы это не потерялось:
- Принятый риск:
POSTбезSec-Fetch-Siteи безOriginпропускается. Клиент, не посылающий ни одного из двух — по-настоящему старый браузер или webview с замороженным движком, — остаётся уязвим к CSRF с любого сайта. Принято сознательно: панель однопользовательская, админ выбирает браузер сам, а строгий режим не «защитил бы» такой клиент, а просто сломал бы в нём панель. Ужесточение — одна строка вoriginAllowed(internal/web/security.go): вернутьfalseвместоtrueв ветке «нет обоих заголовков». - CSRF-токены, привязанные к сессии, не делаются. Проверка origin
закрывает соседний поддомен, но зависит от поведения браузера; токен — нет.
Цена — скрытое поле примерно в двух десятках форм. Триггером вернуться к
вопросу считать появление требования «устойчиво независимо от браузера».
От XSS внутри самой панели не спас бы и токен: код, исполняющийся в origin
панели, отправит запрос сам — против этого работают автоэкранирование
html/template(7.6.7) и CSP, поэтому шаблоны не должны содержать inline-скриптов и inline-стилей.
B. Надёжность и эксплуатация
-
Сессии — решено: хранить в БД, скользящий срок бездействия. Прежнее поведение (только в памяти, absolute TTL 12 ч) заменяется на:
- таблица
sessionsв SQLite (миграция0002) — вход переживает рестарт, редеплой и восстановление из полного бэкапа. В БД лежит SHA-256 от токена, а не сам токен: украденный файл БД или архив бэкапа во вход не превращается, зато браузер, у которого есть исходная cookie, работает и после восстановления; - срок — скользящий, 7 дней бездействия, задаётся
PANEL_SESSION_IDLE_DAYS(целое число дней, какSEND_LOG_RETENTION_DAYS). Абсолютного потолка нет сознательно: у админа, заходящего регулярно, сессия живёт неограниченно долго; - мониторинговые опросы сессию не продлевают. Четыре фрагмента (
/status/fragment,/queue/body,/logtail/body,/sendlog/rows) опрашивают серверevery 5s; продлевай их — и забытая открытая вкладка держала бы вход вечно, а «7 дней бездействия» означало бы «7 дней без открытой вкладки». Активностью считается переход по странице или действие, то есть всё, кроме GET-запросов с заголовкомHX-Request; Max-Agecookie равен сроку и переставляется ровно тогда, когда продлевается строка в БД (запись в БД — не чаще раза в час, чтобы не писать на каждый клик);- смена пароля завершает все сессии, включая ту, из которой её делают → редирект на
/login.
Известное свойство, вытекающее из хранения в БД: восстановление старого бэкапа возвращает и строки сессий, поэтому сессия, разлогиненная уже после снятия бэкапа, оживёт — если её браузер всё ещё хранит cookie и срок не истёк.
- таблица
-
Ротация
mail.log— решено: отказаться отcopytruncateв пользу «переименовать +postfix reload». Прежняя формулировка («несколько строк мониторинга, приемлемо как известное свойство») занижала проблему: тот же тейлер, что рисует экран лога, сверяет и финальные статусы доставки —UpdateStatusвызывается только из internal/logtail, больше ниоткуда. Значит потерянная строкаstatus=sent— это строка журнала отправки, навсегда застрявшая вqueued, то есть тихая порча данных, а не пробел в мониторинге. Окон потери приcopytruncateдва:- до одного интервала опроса (1 с) строк — записанное после последнего
drain()и доtruncateфизически сохранено вmail.log.1, но дескриптор тейлера смотрит на уже обрезанный inode и это пропускает. Это доминирующее окно; - миллисекунды между «
cpдочитал до EOF» иtruncate— эти строки не попадают никуда; дляcopytruncateустранить нельзя.
Решение — ротация переименованием, ровно та механика, которую применяет сам Postfix в
postfix logrotate(mv, затемHUPмастеру): rename атомарен, postlogd продолжает писать в переименованный inode до перезапуска, а тейлер держит дескриптор на том же inode и дочитывает хвост перед переключением на новый файл. Не теряется ничего ни на стороне записи, ни на стороне чтения. Правки:- build/logrotate-mail.conf: убрать
copytruncate, добавитьnocreateиpostrotate /usr/sbin/postfix reload endscript.rotate 14/compress/delaycompressостаются: удержание N файлов (ТЗ 9) — за logrotate, поэтому берётся не самpostfix logrotate(у него нет retention, он лишь переименовывает с меткой времени и жмёт), а его механика; follow()в internal/logtail/logtail.go: при обнаружении смены inode дочитать старый дескриптор ещё раз перед закрытием — иначе остаётся микроокно междуdrain()и проверкой смены файла. Проверкаni.Size() < posсохраняется как страховка от обрезания посторонней схемой ротации, но перестаёт быть основным механизмом;readLogTail()в internal/web/handlers_monitor.go:fs.ErrNotExist— не ошибка, а пустой экран. После rename файла нет, пока Postfix не запишет в него первую строку (порядка секунды в сутки), и баннер ошибки в этот момент — шум.
Цена: один
postfix reloadв сутки — ровно то, что уже делает postfix-cert-reload.sh ради сертификатов, никакой новой машинерии.Проверено на живом 1.0.0 до принятия решения: Postfix 3.7.11 (команда
postfix logrotateесть начиная с 3.4, её реализация вpostfix-script— этоmv+master -t || kill -HUP+sleep 1+ компрессор); postlogd работает под uidpostfix,/var/logпринадлежит root,/var/log/mail.log—root:root 0644и пользователюpostfixна запись недоступен; в образе файла нет — значит создаёт его привилегированная сторона. Отсюда иnocreate: после rename состояние ровно такое же, как при холодном старте контейнера, который заведомо работает.Обязательная проверка на стенде при реализации: после
postfix reloadновый/var/log/mail.logдействительно создаётся и логирование продолжается. Если нет — вернуть создание файла logrotate'у (create 0644 root root), он и так работает под root.Смежное, не решённое (тот же класс потерь, вариантом выше не лечится): при рестарте панели
follow()стартует с конца файла, поэтому строки, записанные пока она не читала, пропускаются; при редеплоеmail.logисчезает вместе с контейнером —/var/logне в volume. В обоих случаях статусы писем, бывших в полёте, остаютсяqueuedнавсегда — вероятно, чаще, чем при ротации. Кандидаты, если решим закрывать: переживать рестарт (запоминать позицию), вынести лог в/data, либо досверять зависшие строки поpostqueue. - до одного интервала опроса (1 с) строк — записанное после последнего
-
Поведение при незаданном
SELFPOST_HOSTNAME— realm SASL и хост setup-ссылки падают вlocalhost. Для реального деплоя hostname обязателен. Вопрос: делать ли фатальную проверку «hostname обязателен» на старте (сейчас — мягкий fallback) — предложение: предупреждать громко в лог, но не падать.
C. CI и тесты
- Нет интеграционного/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). Здесь перечислены лишь как напоминание, что это сознательно отложенный объём, а не забытый.
- 2FA и несколько администраторов — вне объёма v1.x (ТЗ этого не требует: один админ). Кандидаты на 2.x, если понадобятся.
Опциональные фазы — целевой релиз 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) до кодирования.