diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index d583504..07b7abe 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -49,7 +49,22 @@ Hardening сверх обязательного 7.6 закрыт. Здесь о - смена пароля завершает **все** сессии, включая ту, из которой её делают → редирект на `/login`. Известное свойство, вытекающее из хранения в БД: восстановление старого бэкапа возвращает и строки сессий, поэтому сессия, разлогиненная уже после снятия бэкапа, оживёт — если её браузер всё ещё хранит cookie и срок не истёк. -2. **Окно потери строк мониторингового лога при ротации** (`copytruncate`, Фаза 10) — несколько строк `mail.log` могут потеряться в момент ротации. Приемлемо для мониторинга; зафиксировать как известное свойство. +2. **Ротация `mail.log` — решено: отказаться от `copytruncate` в пользу «переименовать + `postfix reload`».** Прежняя формулировка («несколько строк мониторинга, приемлемо как известное свойство») занижала проблему: тот же тейлер, что рисует экран лога, сверяет и финальные статусы доставки — `UpdateStatus` вызывается **только** из [internal/logtail](../internal/logtail/logtail.go), больше ниоткуда. Значит потерянная строка `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](../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](../internal/logtail/logtail.go): при обнаружении смены inode дочитать старый дескриптор ещё раз перед закрытием — иначе остаётся микроокно между `drain()` и проверкой смены файла. Проверка `ni.Size() < pos` сохраняется как страховка от обрезания посторонней схемой ротации, но перестаёт быть основным механизмом; + - `readLogTail()` в [internal/web/handlers_monitor.go](../internal/web/handlers_monitor.go): `fs.ErrNotExist` — не ошибка, а пустой экран. После rename файла нет, пока Postfix не запишет в него первую строку (порядка секунды в сутки), и баннер ошибки в этот момент — шум. + + **Цена:** один `postfix reload` в сутки — ровно то, что уже делает [postfix-cert-reload.sh](../build/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 работает под uid `postfix`, `/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`. 3. **Поведение при незаданном `SELFPOST_HOSTNAME`** — realm SASL и хост setup-ссылки падают в `localhost`. Для реального деплоя hostname обязателен. **Вопрос:** делать ли фатальную проверку «hostname обязателен» на старте (сейчас — мягкий fallback) — предложение: предупреждать громко в лог, но не падать. ### C. CI и тесты diff --git a/docs/progress.md b/docs/progress.md index 9910f15..cc86a14 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -35,7 +35,7 @@ ## Текущее состояние - **Выполнено и принято:** базовый линейный план 0→11 (v1.0; аудит безопасности ТЗ 7.6 — полное соответствие), Фаза 12 (UI/UX), Фаза 13 (страница `/status`, DNS-проверки домена) и Фаза 14 (security-заголовки, проверка origin, cookie `__Host-` + обнаружение дублей, документация про `/data/setup-token`). Что именно сделано — в `git log` и `CHANGELOG.md`, здесь не дублируется. -- **Решено, но ещё не реализовано:** пункт **B.1** плана — сессии переезжают в SQLite (скользящий срок бездействия 7 дней, `PANEL_SESSION_IDLE_DAYS`, опросы не продлевают, смена пароля разлогинивает всех). Параметры согласованы полностью, кода нет: делать отдельным шагом. +- **Решено, но ещё не реализовано:** пункты **B.1** и **B.2** плана. B.1 — сессии переезжают в SQLite (скользящий срок бездействия 7 дней, `PANEL_SESSION_IDLE_DAYS`, опросы не продлевают, смена пароля разлогинивает всех). B.2 — ротация `mail.log` уходит с `copytruncate` на «переименовать + `postfix reload`» (правки в `logrotate-mail.conf`, `follow()` в `internal/logtail`, `readLogTail()` в `internal/web`; на стенде проверить, что после reload новый `mail.log` создаётся). Параметры обоих согласованы полностью, кода нет: делать отдельными шагами. - **Дальше — то, что перечислено в `implementation-plan.md`:** открытые вопросы разделов B–D (надёжность и эксплуатация, e2e в CI, указатель на объём 2.x) и принятые риски раздела A (`POST` без `Sec-Fetch-Site`/`Origin` пропускается, токенов нет); опциональная **Фаза O1+** (входящий релей, линия 2.x.x, требует согласования). - **Прод:** `selfpost.example.com`, реальный Let's Encrypt сертификат, живой e2e (DKIM/SPF pass). Контейнер там всё ещё на образе v1.0 — Фаза 14 в него не выкатывалась. При апгрейде: админа один раз разлогинит (сменилось имя cookie), а от reverse-proxy требуется передача исходного `Host` (Apache-фрагмент из `deploy/` это делает).