Files
selfpost/docs/progress.md
T
mixeme 1f548dfc7a fix(logtail): keep mail.log in /data and reconcile stuck rows (v1.x closure phase 2)
Move the delivery log from the ephemeral /var/log to /data/log/mail.log so
the lines that resolve a queued send-log row survive a container recreate.
postlogd writes it as postfix, the panel reads it through the selfpost group
(dir 2750, file 0640, normalised every start); backups exclude log/.

Close the residual gap with a queue sweep: rows queued for over two minutes
whose id postqueue -p no longer lists are marked bounced. The sweep waits
until the tailer has read the log to its end and does nothing when the queue
cannot be listed, so a message in flight is never touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 11:35:27 +03:00

30 KiB
Raw Blame History

Прогресс реализации SelfPost

Живой трекер состояния. Переживает /clear — читается первым при возобновлении работы. План (открытые вопросы для v1.0/v1.x): implementation-plan.md. Линия 2.x.x (входящий релей, роль администратора домена): roadmap.md. Продукт: product.md, устройство: architecture.md. Процесс разработки: development.md. Принятые риски безопасности: security.md. История релизов: CHANGELOG.md. История сделанного по фазам (0→13, все закрыты) — в git log и в CHANGELOG, здесь не дублируется.

Как возобновить после сброса контекста

  1. Прочитать этот файл (текущее состояние, что дальше).
  2. Открыть implementation-plan.md — там остаётся предрелизная ревизия безопасности (§ D); линия 2.x.x — в roadmap.md; принятые риски — в security.md; as-built B.1C.4 — в architecture.md и development.md.
  3. При необходимости — architecture.md и product.md.
  4. Продолжить с пункта «Следующий шаг».

Модель по типу работы

Правило: безопасность / инфра / риск-критичное → Opus; UI / документация / бойлерплейт → Sonnet; тривиальная механика → Haiku.

Исключение — ревизия (не написание) кода: предрелизная проверка на уязвимости (implementation-plan.md § D) делается моделью Fable, чтобы проверял не тот, кто писал.

Коммиты

Коммит на каждом осмысленном шаге (не каждое сохранение файла, но и не только конец фазы): рабочий под-функционал, зелёная сборка, конец фазы. Минимум — один коммит на закрытую фазу + промежуточные на связные под-шаги. Ветка main (если пользователь не попросит отдельную). Push/PR — только по явной команде. Сообщение коммита завершается трейлером Co-Authored-By: Claude <модель> <noreply@anthropic.com> — с той моделью, которая этот шаг делала (на момент Фазы 14 — Claude Opus 5).

На каждом таком шаге — запись в CHANGELOG.md под [Unreleased] (формат Keep a Changelog). При явном решении зарезать версию — секция [Unreleased] переименовывается в [X.Y.Z] - дата, заводится новая пустая [Unreleased]. Тег/пуш образа — только по явному запросу (см. workflow release.yml).

Протокол закрытия фазы/крупного шага

Перед /clear в конце каждого законченного шага Claude:

  1. Обновляет этот файл: «Текущее состояние» → что изменилось, что дальше.
  2. Проверяет применимые критерии «Готово, когда…».
  3. Дописывает CHANGELOG.md под [Unreleased].
  4. Делает финальный коммит шага.

Текущее состояние

  • Выполнено и принято: базовый линейный план 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 (internal/store/migrations/0002_sessions.sql, internal/store/sessions.go, internal/web/session.go) — хранится SHA-256 токена, не сам токен; скользящий срок бездействия PANEL_SESSION_IDLE_DAYS (по умолчанию 7 дней, без абсолютного потолка); запись в БД продлевается не чаще раза в час (renewThreshold); опросы мониторинга (GET с HX-Request) продление не триггерят (isSessionActivity в internal/web/middleware.go); Max-Age cookie выставляется тем же значением при логине и при продлении (setSessionCookie); смена пароля разлогинивает все сессии кроме текущей (уже было, теперь через БД). Проверено на стенде: логин → рестарт процесса панели → сессия жива по старой cookie; HX-Request-опрос и повторный GET внутри часового окна не шлют Set-Cookie. go vet/go test ./.../gofmt -l . чистые.
  • B.2 реализован (не выкачен на прод): ротация mail.log ушла с copytruncate на «переименовать + postfix reload» — build/logrotate-mail.conf (nocreate заменён на create 0644 root root не по плану, а по стендовой проверке: после reload Postfix пересоздаёт лог сам только в момент следующей фактической записи и с режимом 0600, недоступным непривилегированной панели, — create в logrotate закрывает это, отдавая файл ей же на 644 сразу после переименования); follow() в internal/logtail/logtail.go при обнаружении смены inode дочитывает старый дескриптор ещё раз перед переключением; readLogTail() в internal/web/handlers_monitor.go считает отсутствующий файл пустым экраном, а не ошибкой. Проверено на стенде (отдельный контейнер selfpost:b2test2): цикл трафик → принудительная ротация → файл пуст и сразу читаем непривилегированным uid панели (0 читает mail.log сразу после rename, без окна недоступности) → новый трафик после ротации уходит в новый файл на 644, ничего не потеряно по обе стороны rename. go vet/go test ./.../gofmt -l . чистые (на стенде; локально на Windows TestFollowTailsAndRotates падает — rename открытого файла запрещён ОС, к делу не относится).
  • B.3 реализован (не выкачен на прод): build/entrypoint.sh проверяет SELFPOST_HOSTNAME до postfix-config.sh и до supervisord — при пустом значении exit 1 с развёрнутым текстом ошибки (что это за имя, почему обязательно, пример, где задаётся); плюс синтаксическая проверка через case: минимум одна точка, без схемы/порта/пробелов (*://*, *:*, пробел/таб — тот же класс тихого спам-отказа, что и пустое значение). saslRealm() и fallback в postfix-config.sh не тронуты — после гейта эти ветки мертвы. Заодно отмечена обязательность переменной в README.md и deploy/.env.example. Проверено на стенде (отдельный образ selfpost:b3test, cap-list как в поставляемом compose): без переменной — exit 1 с ожидаемым текстом, без бесконечного тихого retry; https://mail.example.com:465 и localhost отклонены с понятными сообщениями; валидный mail.example.com — обычный старт, все процессы supervisord поднимаются. go vet/go test ./... чистые.
  • C.4 реализован (не выкачен на прод — это CI/тестовая инфраструктура, а не образ): герметичный контейнерный e2e отдельным Go-модулем test/e2e/ (свой go.mod, не подхватывается go test ./... основного модуля) поверх поставляемого deploy/docker-compose.yml плюс test/e2e/compose.override.yml (самоподписанный сертификат, PANEL_COOKIE_SECURE=false, SELFPOST_HOSTNAME=mail.e2e.test, высокие порты 20465/20587/20080, изолированный compose-проект selfpost-e2e, свой --project-directory — прод на том же хосте не задет). Герметичная почта: CoreDNS (test/e2e/dns/Corefile — авторитетна только для e2e.test, file-плагин с саб-директивой reload перечитывает db.zone по mtime, без сигналов) плюс smtp-sink из пакета postfix (test/e2e/sink/) как sink-MX. Сценарий (test/e2e/*_test.go): старт контейнера → все supervisord-программы RUNNING (postfix-reloadSTOPPED) → токен из /data/setup-token → setup → login → добавление домена → DKIM-запись скраплена со страницы панели и опубликована в фейковую зону → добавление приложения → SMTP AUTH на 465 → письмо на sink → DKIM-подпись проверена (go-msgauth/dkim с кастомным LookupTXT через CoreDNS) против ключа из DNS, не из панели напрямую → send-log queued → sent. Негативы: без AUTH, relay на чужой домен без AUTH, sender/login mismatch (reject_sender_login_mismatch репортится Postfix'ом на RCPT, не MAIL — smtpd_delay_reject=yes по умолчанию), L1-лимит (anvil, override RATE_LIMIT_MESSAGES_PER_IP=50 — специально высокий, чтобы остальные под-тесты не расходовали общий бюджет по IP раньше времени; сам тест шлёт до 60 раз, ждёт отказа), L2-лимит через панель (домен/приложение → rejected-строка в send-log), fail-open journal-milter'а (supervisorctl stop panel, письмо всё равно принято, контейнер жив), пустой/синтаксически неверный SELFPOST_HOSTNAME (отдельный один-разовый контейнер, не общий стенд), сессия переживает docker restart (плюс явное ожидание готовности smtps-порта после рестарта — панель и Postfix поднимаются независимо). make e2e — локальный прогон. Найдено и исправлено по ходу стендовой проверки: reload — саб-директива file-плагина CoreDNS, а не отдельный топ-левел плагин (топ-левел reload следит за самим Corefile, не за зоной); docker compose build.context резолвится относительно --project-directory, а не относительно файла, где объявлен; smtp-sink отказывается стартовать от root без -u; html/template эскейпит + в &#43; даже в тексте — скрапер значений со страницы обязан html.UnescapeString; проверки состояния сразу после up/restart должны поллиться, а не разово опрашиваться (supervisord/postfix поднимаются не мгновенно). Проверено на стенде: make e2e — зелёный (go vet/gofmt -l тоже чистые в обоих модулях). release.yml переработан: job prepare (версия из тега) → матрица [ubuntu-latest, ubuntu-24.04-arm] — каждая нативно собирает образ (--load), прогоняет e2e, пушит тег X.Y.Z-amd64/X.Y.Z-arm64 → job mergedocker buildx imagetools create в единый тег X.Y.Z; setup-qemu-action убран. Не проверено вживую (нельзя без реального тега): сам workflow на GitHub Actions — синтаксис вычитан, логика идентична локальному make e2e пути.
  • Документация: план D1–D9 закрыт (documentation-plan.md — только метод и правила поддержки). Хвост v1.x — roadmap.md § «v1.x — хвост документации и деплоя»; из него остался только бамп тега образа (Quick start и docs/logo закрыты).
  • Рецензирование кодовой базы (2026-08-05, 522425a): 10 разделов (архитектура, качество, docs, GUI, legacy, риски) плюс приоритизированный план доработок фазами 0–3. Критичных багов не найдено; единственным блокером релиза названа § D. План выполнен целиком (см. записи ниже), поэтому сам документ docs/code-review.md удалён — незакрытые пункты унесены в roadmap.md (разбиение internal/web, индекс документации в README, адаптивный интервал опроса, CONTRIBUTING.md), остальное либо сделано, либо уже описано в architecture.md / security.md / комментариях кода. Текст ревизии — в git-истории.
  • § D выполнен (2026-08-06): предрелизная ревизия безопасности моделью Fable — диф от аудита v1.0 (Фаза 11, bd64e80) до HEAD + полный проход по чек-листу security.md (бывшее ТЗ 7.6). Эксплуатируемых находок нет; одна правка defence-in-depth (-- перед логином в argv saslpasswd2, internal/app/sasl.go + тест). Принятые риски не пополнились. Детали — implementation-plan.md § D и CHANGELOG [Unreleased]/Security. Локально go vet/go test ./internal/app/... чистые; падения internal/domain (TestWriteLoadPrivateKeyRoundtrip, TestRenderTables) и internal/logtail (TestFollowTailsAndRotates) — Windows-специфика (права файлов/\ в путях/rename открытого файла), на Linux CI зелено.
  • Фаза 1 плана ревизии выполнена (2026-08-06) (doc/code hygiene, P1): cleanup ~30 stale «Phase N» комментариев в коде и shell-скриптах; исправлен stale-комментарий в handlers_domains.go; ADR CSRF (Origin vs токены) добавлен в security.md; known-limitations по log-tailer уже был в architecture.md § Log tailer — отдельного действия не потребовалось; docs/logo в roadmap.md закрыт (каталога нет, критерию соответствует); gofmt -l добавлен в CI (.github/workflows/test.yml). gofmt/go vet/go test ./... чистые в обоих модулях.
  • Фаза 1.5 плана ревизии выполнена (2026-08-06) (шифрование резервных копий, P1): новый пакет internal/secretfile — конверт magic SELFPOST1 | type | scrypt-параметры | salt | nonce-prefix + поток 64 KiB чанков AES-256-GCM, каждый с AAD header+counter+last, поэтому обрезка, перестановка и подмена не открываются (стриминг в обе стороны — полный бэкап не держится в памяти). Панель: чекбокс «Encrypt with a password» в форме полного бэкапа и экспорта домена (общий партиал templates/encrypt_fields.html, показ/очистка полей — panel.js, без inline-скриптов), импорт домена принимает .spde (шифрование определяется по magic, не по расширению) с полем пароля. CLI selfpost-backup: пишет .spbk при заданном пароле и умеет -decrypt (иначе зашифрованный бэкап нечем распаковать при restore); пароль — только SELFPOST_BACKUP_PASSWORD / -password-file, никогда argv. Умолчание не изменилось: галочка снята — прежние .tar.gz / .json байт в байт. Тесты: round-trip по размерам (0, границы чанка, несколько чанков), неверный пароль, обрезка, перестановка чанков, порча байта, чужие KDF-параметры; валидация формы пароля; round-trip CLI create→decrypt→tar. Docs: README § Encrypting a backup or export, security.md § «Резервная копия и экспорт домена» + принятый риск (шифрование опционально), architecture.md § Persistence. gofmt/go vet/go test ./... чистые (кроме известных Windows-падений internal/domain, internal/logtail). E2E-сценарий не добавлялся: в test/e2e/ бэкапа не было и раньше, а прогнать новый тест локально нечем (нет Docker) — кандидат при следующем прогоне на стенде.
  • Фаза 2 плана ревизии выполнена (2026-08-06) (GUI polish, P2): опрос мониторинговых страниц не уходит на сервер, пока вкладка скрыта — фильтр повешен на htmx:beforeRequest в panel.js, а не на встроенный в htmx фильтр триггера (тот вычисляется через new Function, что CSP панели default-src 'self' без unsafe-eval молча ломает); тёмная тема переписана с каскада !important на переопределение CSS-переменных в одном блоке prefers-color-scheme: dark; дублирующее правило main { max-width } сведено к одному базовому плюс задокументированные постраничные оверрайды. Только CSS/JS, поведения сервера не касается; вживую не проверялось (нет Docker локально) — кандидат на следующий прогон на стенде.
  • Фаза 3 плана ревизии выполнена (2026-08-06) (operational improvements, P2P3): (1) log-tailer сохраняет позицию чтения — таблица logtail_state (миграция 0003, internal/store/logtail.go) хранит offset + отпечаток первых 512 байт лога, internal/logtail/offset.go решает откуда стартовать: отпечаток совпал → продолжаем с offset (дочитывается хвост, написанный пока панель лежала); не совпал (лог сменился/пересоздан) → читаем файл с начала (повторный разбор безвреден, UpdateStatus идемпотентен); записи нет вовсе (первый запуск) → с конца, как раньше. Запись offset — не чаще раза в 5 с, плюс форс при ротации и на выключении; сохраняется позиция потреблённых байт (минус недочитанная частичная строка). (2) L2-лимит перестал промахиваться при параллельных сессиях: между проверкой на MAIL FROM и вставкой строки на end-of-message сообщение не видно в БД, поэтому N одновременных сессий пропускали друг друга — теперь к счёту из БД добавляются «в полёте» (internal/milter/inflight.go, общий на процесс реестр резерваций); резервация освобождается после записи в send-log, на ABORT и по TTL 10 минут (у go-milter нет колбэка на закрытие соединения, а вечная резервация — это fail-closed-дрейф, которого у лимитера быть не должно). Транзакция «count+insert», как предлагал review, невозможна буквально: эти два шага разнесены по разным стадиям SMTP-транзакции. Тесты: restart/rotation-resume для tailer'а, четыре сценария резерваций для лимита. gofmt/go vet чистые; go test ./... — падения только известные Windows-специфичные (internal/domain, TestFollowTailsAndRotates). Не проверено на стенде (нет Docker локально) — кандидат на следующий прогон на стенде.
  • Добор по плану ревизии выполнен (2026-08-06): (1) проект переехал на единственную площадку — GitHub (Codeberg уходит): вместе с URL, лицензионными шапками SVG/HTML и docs переехал путь Go-модуля на github.com/mixeme/selfpost (go.mod, test/e2e/go.mod, все импорты, MODULE в Makefile, -ldflags в Dockerfile и development.md) — оставлять импорты на исчезающем хосте нельзя, go get/go install сломались бы; (2) ссылки на архивную спецификацию убраны из кода целиком — не только «spec 7.x», как просило ревью, но и «spec 4/5/6/8/9», страдавшие тем же, каждая заменена на живой документ с секцией там, где документ большой; (3) architecture.md § Code layers — диаграмма слоёв (A2); (4) TestParseDelivery расширен экзотикой mail.log — и вскрыл реальный баг: шаблон брал status= жадно, то есть последнее вхождение в строке, а Postfix дописывает ответ удалённого сервера дословно, поэтому отказ с status=sent в тексте ответа попадал в журнал как доставленный (исправлено на ленивый разбор); (5) CONTRIBUTING.md перенесён в 2.x, бамп тега образа и git-тег оставлены в roadmap.md § v1.x. gofmt/go vet чистые в обоих модулях, go test ./... — падения только известные Windows-специфичные (internal/domain, TestFollowTailsAndRotates). На стенде не проверялось (нет Docker локально).
  • v1.x-closure Фаза 1 выполнена (2026-08-08) (адаптивный опрос мониторинга): четыре HTMX-фрагмента (status_body, mail_queue_body, system_log_body, deliveries_rows) несут data-poll и hx-trigger="load" только для первого запроса; panel.js планирует следующий опрос после htmx:afterSwap / htmx:responseError — 5 s при активности оператора на странице, 30 s при видимой, но простаивающей вкладке, 0 при скрытой (beforeRequest + сброс таймеров на visibilitychange). Без hx-trigger="every … [expr]" (CSP / unsafe-eval). Docs: architecture.md, roadmap.md, CHANGELOG. На стенде не проверялось.
  • v1.x-closure Фаза 2 выполнена (2026-08-08) (send-log vs mail.log): (1) лог переехал из эфемерного /var/log в /data/log/mail.logmaillog_file в postfix-config.sh и MAIL_LOG в cmd/panel/main.go берут один и тот же дефолт, entrypoint.sh создаёт каталог 2750 postfix:selfpost и нормализует файлы в 0640 на каждом старте (пишет postlogd от postfix, читает панель по общей группе; postlogd сам создал бы файл в 0600, поэтому создаём его мы, а logrotate — create 0640 postfix selfpost), каталог исключён из общего chown … panel в начале entrypoint и из архива бэкапа (internal/backup, диагностика, а не состояние). (2) Строки, чьи delivery-строки потеряны безвозвратно, больше не висят queued вечно: postfix.QueueIDs разбирает postqueue -p, store.ListQueuedOlderThan отдаёт кандидатов, internal/logtail раз в 5 минут закрывает как bounced те, чьего queue-id в очереди уже нет (grace 2 мин). Три предохранителя: sweep стартует только после того, как tailer впервые дочитал лог до конца (на рестарте ответ лежит в самом логе), grace покрывает письмо «в полёте», нечитаемый postqueue не трогает ничего. Ложно-отрицательный bounced — новый принятый риск в security.md. Тесты: парсер очереди (включая строку-причину deferred и «Mail queue is empty»), ListQueuedOlderThan, три сценария sweep, исключение log/ из бэкапа. gofmt/go vet чистые в обоих модулях, go test ./... — падения только известные Windows-специфичные (internal/domain, TestFollowTailsAndRotates). На стенде не проверялось (нет Docker локально): образ не собирался, контейнер не стартовал — права на /data/log, чтение лога панелью, прокрутка logrotate и postqueue -p из-под panel подлежат проверке при выкате.
  • Дальше: v1.x-closure v1.x-closure-plan.mdФазы 12 закрыты. Следующий шаг — Фаза 3 (docs: development.md, README, удаление планов и docs/archive/). Релизный гейт по коду закрыт; бамп тега образа и git tag — по явной команде (Фазы 4–5).
  • Принятые рискиsecurity.md. Опционально v1.x / 2.xroadmap.md (хвост документации, send-log gaps, Фаза O1+, роль администратора домена).
  • Прод: инстанс с реальным Let's Encrypt сертификатом и живым deliverability (DKIM/SPF pass). Контейнер там всё ещё на образе v1.0 — Фаза 14 в него не выкатывалась. При апгрейде: админа один раз разлогинит (сменилось имя cookie), а от reverse-proxy требуется передача исходного Host (Apache-фрагмент из deploy/ это делает).

Рабочая петля (dev loop) — ВАЖНО

Сборка, unit-тесты и e2e требуют Go 1.26+ и Docker + Compose v2 — см. development.md. Конкретная связка машин (всё локально, отдельный сервер, только CI) у каждого разработчика своя; источник истины — git-репозиторий.