From 25cc34426c152f15453c4379911c3cdae1f132dc Mon Sep 17 00:00:00 2001 From: Mikhail Yenuchenko Date: Sun, 2 Aug 2026 22:23:35 +0300 Subject: [PATCH] =?UTF-8?q?docs:=20decide=20item=20B.2=20=E2=80=94=20rotat?= =?UTF-8?q?e=20mail.log=20by=20rename=20+=20postfix=20reload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/implementation-plan.md | 17 ++++++++++++++++- docs/progress.md | 2 +- 2 files changed, 17 insertions(+), 2 deletions(-) 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/` это делает).