Codeberg is being retired as the project's public site, so every reference now points at GitHub. That includes the Go module path (codeberg.org/mix/selfpost → github.com/mixeme/selfpost): leaving an import path on a host that is going away would break `go get` and `go install`, so this is not only a docs change. Touches go.mod, test/e2e/go.mod, all imports, Makefile MODULE, the -ldflags version stamp in build/Dockerfile and docs/development.md, the licence headers in the SVG/HTML assets, and README (no more primary/mirror pair). Comments no longer cite the archived specification. "spec 7.6.1", "spec 5.1" and friends pointed into docs/archive/specification-v1.0.md, which is marked as not a source of truth; each is now a reference to the live document that owns the subject — architecture.md (with section), product.md, security.md or the README. The review only asked for the 7.x refs (code-review.md § 4), but 4/5/6/ 8/9 had the same defect, so they went too. Comments only, no behaviour change. Also closes the remaining review items: architecture.md gained a Code layers section with the layer diagram (A2), and TestParseDelivery gained the exotic mail.log cases (§ 3). Fixes a bug that last test found: the delivery-line pattern matched status= greedily, taking the *last* occurrence on the line. Postfix appends the remote server's reply verbatim, so a rejection whose reply quoted "status=sent" was filed as a delivered message in the send log. It now takes the first status= after the recipient, which is the real field. R7 (CONTRIBUTING.md) moved to roadmap 2.x — one developer, no external PR flow, so the file would have no audience yet. R1 (compose image tag) and the git tag stay in roadmap § v1.x as the release-commit steps. gofmt/go vet clean on both modules; go test ./... green except the three known Windows-only failures (file perms, backslash paths, renaming an open file). Not exercised on the dev server — no Docker locally. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
27 KiB
Прогресс реализации SelfPost
Живой трекер состояния. Переживает /clear — читается первым при возобновлении работы.
План (открытые вопросы для v1.0/v1.x): implementation-plan.md.
Линия 2.x.x (входящий релей, роль администратора домена): roadmap.md.
Продукт: product.md, устройство: architecture.md.
Процесс разработки: development.md. Принятые риски безопасности: security.md.
Рецензирование кодовой базы и план доработок: code-review.md.
История релизов: CHANGELOG.md.
История сделанного по фазам (0→13, все закрыты) — в git log и в CHANGELOG, здесь не дублируется.
Как возобновить после сброса контекста
- Прочитать этот файл (текущее состояние, что дальше).
- Открыть
implementation-plan.md— там остаётся предрелизная ревизия безопасности (§ D); линия 2.x.x — вroadmap.md; принятые риски — вsecurity.md; as-built B.1–C.4 — вarchitecture.mdиdevelopment.md. - При необходимости — architecture.md и product.md.
- Продолжить с пункта «Следующий шаг».
Модель по типу работы
Правило: безопасность / инфра / риск-критичное → 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:
- Обновляет этот файл: «Текущее состояние» → что изменилось, что дальше.
- Проверяет применимые критерии «Готово, когда…».
- Дописывает
CHANGELOG.mdпод[Unreleased]. - Делает финальный коммит шага.
Текущее состояние
- Выполнено и принято: базовый линейный план 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-Agecookie выставляется тем же значением при логине и при продлении (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.example.com, отдельный контейнерselfpost:b2test2): цикл трафик → принудительная ротация → файл пуст и сразу читаем непривилегированным uid панели (0 читаетmail.logсразу после rename, без окна недоступности) → новый трафик после ротации уходит в новый файл на 644, ничего не потеряно по обе стороны rename.go vet/go test ./.../gofmt -l .чистые (на dev-сервере; локально на WindowsTestFollowTailsAndRotatesпадает — 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.example.com, отдельный образ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-reload—STOPPED) → токен из/data/setup-token→ setup → login → добавление домена → DKIM-запись скраплена со страницы панели и опубликована в фейковую зону → добавление приложения → SMTP AUTH на 465 → письмо на sink → DKIM-подпись проверена (go-msgauth/dkimс кастомнымLookupTXTчерез CoreDNS) против ключа из DNS, не из панели напрямую → send-logqueued → sent. Негативы: без AUTH, relay на чужой домен без AUTH, sender/login mismatch (reject_sender_login_mismatchрепортится Postfix'ом на RCPT, не MAIL —smtpd_delay_reject=yesпо умолчанию), L1-лимит (anvil, overrideRATE_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— локальный/dev-server прогон. Найдено и исправлено по ходу стендовой проверки:reload— саб-директиваfile-плагина CoreDNS, а не отдельный топ-левел плагин (топ-левелreloadследит за самим Corefile, не за зоной);docker compose build.contextрезолвится относительно--project-directory, а не относительно файла, где объявлен;smtp-sinkотказывается стартовать от root без-u;html/templateэскейпит+в+даже в тексте — скрапер значений со страницы обязанhtml.UnescapeString; проверки состояния сразу послеup/restartдолжны поллиться, а не разово опрашиваться (supervisord/postfix поднимаются не мгновенно). Проверено на dev-сервере (selfpost.example.com):make e2e— зелёный (go vet/gofmt -lтоже чистые в обоих модулях).release.ymlпереработан: jobprepare(версия из тега) → матрица[ubuntu-latest, ubuntu-24.04-arm]— каждая нативно собирает образ (--load), прогоняет e2e, пушит тегX.Y.Z-amd64/X.Y.Z-arm64→ jobmerge—docker 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): code-review.md — 10 разделов (архитектура, качество, docs, GUI, legacy, риски), приоритизированный план реализации и маршрутизация моделей. Критичных багов не найдено; блокер релиза — § D ниже.
- § D выполнен (2026-08-06): предрелизная ревизия безопасности моделью Fable — диф от аудита v1.0 (Фаза 11,
bd64e80) до HEAD + полный проход по чек-листу security.md (бывшее ТЗ 7.6). Эксплуатируемых находок нет; одна правка defence-in-depth (--перед логином в argvsaslpasswd2,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) (code-review.md § Фаза 1 — 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 ./...чистые в обоих модулях (dev-server). - Фаза 1.5 выполнена (2026-08-06) (code-review.md § Фаза 1.5 — шифрование резервных копий, P1): новый пакет
internal/secretfile— конвертmagic SELFPOST1 | type | scrypt-параметры | salt | nonce-prefix+ поток 64 KiB чанков AES-256-GCM, каждый с AADheader+counter+last, поэтому обрезка, перестановка и подмена не открываются (стриминг в обе стороны — полный бэкап не держится в памяти). Панель: чекбокс «Encrypt with a password» в форме полного бэкапа и экспорта домена (общий партиалtemplates/encrypt_fields.html, показ/очистка полей —panel.js, без inline-скриптов), импорт домена принимает.spde(шифрование определяется по magic, не по расширению) с полем пароля. CLIselfpost-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) — кандидат при следующем прогоне на dev-сервере. - Фаза 2 выполнена (2026-08-06) (code-review.md § Фаза 2 — 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 локально) — кандидат на следующий прогон на dev-сервере. - Фаза 3 выполнена (2026-08-06) (code-review.md § Фаза 3 — operational improvements, P2–P3): (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 локально) — кандидат на следующий прогон на dev-сервере. - Добор по code-review выполнен (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» из § 4 ревью, но и «spec 4/5/6/8/9», страдавшие тем же, каждая заменена на живой документ с секцией там, где документ большой; (3) architecture.md § Code layers — диаграмма слоёв (A2); (4)TestParseDeliveryрасширен экзотикой mail.log — и вскрыл реальный баг: шаблон бралstatus=жадно, то есть последнее вхождение в строке, а Postfix дописывает ответ удалённого сервера дословно, поэтому отказ сstatus=sentв тексте ответа попадал в журнал как доставленный (исправлено на ленивый разбор); (5) R7 (CONTRIBUTING.md) перенесён в 2.x, R1 и git-тег оставлены в roadmap.md § v1.x.gofmt/go vetчистые в обоих модулях,go test ./...— падения только известные Windows-специфичные (internal/domain,TestFollowTailsAndRotates). На стенде не проверялось (нет Docker локально). - Дальше: релизный гейт (Фаза 0) закрыт по существу — e2e C.4 и ревизия § D пройдены; остаются только шаги, которые делаются в момент резки версии (бамп тега образа в compose, git tag) по явной команде пользователя. Все polish-фазы из code-review.md (1, 1.5, 2, 3) закрыты.
- Принятые риски — security.md. Опционально v1.x / 2.x — roadmap.md (хвост документации, send-log gaps, Фаза O1+, роль администратора домена).
- Прод:
selfpost.example.com, реальный Let's Encrypt сертификат, живой e2e (DKIM/SPF pass). Контейнер там всё ещё на образе v1.0 — Фаза 14 в него не выкатывалась. При апгрейде: админа один раз разлогинит (сменилось имя cookie), а от reverse-proxy требуется передача исходногоHost(Apache-фрагмент изdeploy/это делает).
Рабочая петля (dev loop) — ВАЖНО
Локально (Windows, <repo>) нет Go и Docker — только редактирование и git. Вся сборка/тесты идут на dev-сервере selfpost.example.com (Debian 12 bookworm, тот же, что базовый образ; провижён под разработку). Цикл: править локально → залить дерево на сервер → go build/go vet/docker build/тесты там. rsync в локальном git-bash нет, поэтому дерево едет tar'ом по ssh: tar -czf - --exclude=.git . | ssh root@selfpost.example.com 'rm -rf /root/selfpost-src && mkdir -p /root/selfpost-src && tar -xzf - -C /root/selfpost-src'; Go на сервере — в /usr/local/go/bin (не в PATH по умолчанию); образ — docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev .. Источник истины и git-история — локальный репозиторий; сервер — только исполнитель сборки/тестов. Подключение: ssh root@selfpost.example.com (по ключу).