Adds application accounts bound to domains: a SASL login/password in sasldb2, a per-application address mode (wildcard @domain or an explicit list), and matching smtpd_sender_login_maps bindings — with create, list, edit-mode, delete and password regeneration (spec 4.1, 5.1, 7.2.5-9). Generated passwords are shown exactly once and never stored in plaintext (7.6.1). - internal/store/applications.go: transactional CRUD; globally unique login; ListBindings (address->login) as the map source; logins-by- domain for pre-cascade SASL cleanup. - internal/app: saslpasswd2 wrapper (password via stdin, login as a whitelisted argv element, no shell — 7.6.3); strong base64url password; address validation that enforces domain ownership before any config write (7.6.2); service orchestrating store + sasldb2 + map with full rollback on partial failure. - internal/postfix: sender_login_maps regenerated as a pure function of the registry (many-to-one logins merged per address), atomic write, injection backstop (7.6.4). - Postfix reload, corrected: `postfix start-fg` forks a separate master, so signalling the supervised process never reaches it. Reload now runs the canonical `postfix reload` via a one-shot supervisord program the unprivileged panel triggers over the group control socket. Verified in mail.log. - domain.Service.Delete purges the domain's SASL accounts, then cascades, then rebuilds the sender map and reloads; manual reload now covers both OpenDKIM and Postfix. - web: application management in the domain page, one-time credential shown inline; postfix joins the selfpost group and entrypoint normalises /data/sasl and /data/postfix (setgid, group-readable) with self-heal. Verified on the dev server: gofmt/vet/test green, image builds, and a container e2e covers the full application lifecycle, domain-delete cascade, restart persistence, and a real postfix reload. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
29 KiB
Прогресс реализации SelfPost
Живой трекер фаз. Переживает /clear — читается первым при возобновлении работы.
План фаз: implementation-plan.md. ТЗ: specification.md.
Как возобновить после сброса контекста
- Прочитать этот файл (текущая фаза, статус, что сделано, что дальше).
- Прочитать соответствующую фазу в
implementation-plan.md. - При необходимости — детали в
specification.md. - Продолжить с пункта «Следующий шаг».
Рекомендуемая модель по фазам
| Фаза | Модель | Почему |
|---|---|---|
| 0 — каркас + спайк milter | Opus | архитектура + главный технический риск (7.3) |
| 1 — Docker/supervisord/обёртка | Opus | тонкая логика холодного старта сокетов |
| 2 — SQLite/setup-link/auth | Opus | безопасность 7.6 (крипто-токен, сессии, bcrypt) |
| 3 — домены + OpenDKIM | Opus | генерация конфигов + exec-safety (7.6.3–4) |
| 4 — приложения + SASL + sender_login_maps | Opus | риск open relay / привязки отправителя |
| 5 — полный Postfix | Opus | самый чувствительный тракт доставки |
| 6 — journal-milter | Opus | наивысший риск (баг ломает релей) |
| 7 — UI мониторинга | Sonnet | шаблоны/CRUD, рутинно |
| 8 — rate limit L2 | Opus | логика лимитов в milter |
| 9 — бэкап/restore/экспорт | Opus | целостность данных, версионирование |
| 10 — деплой + docs | Sonnet | compose-файлы и документация |
| 11 — security-проход | Opus | аудит соответствия 7.6 |
Правило: безопасность / инфра / риск-критичное → Opus; UI / документация / бойлерплейт → Sonnet; тривиальная механика → Haiku.
Коммиты
Коммит на каждом осмысленном шаге (не каждое сохранение файла, но и не только конец фазы): рабочий под-функционал, зелёная сборка, конец фазы. Минимум — один коммит на закрытую фазу + промежуточные на связные под-шаги. Ветка main (если пользователь не попросит отдельную). Push/PR — только по явной команде. Сообщение коммита завершается трейлером Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>.
Протокол закрытия фазы
Перед /clear в конце каждой фазы Claude:
- Обновляет этот файл: статус фазы → ✅, заполняет «Сделано» и «Следующий шаг».
- Проверяет критерии «Готово, когда…» из плана.
- Пишет одну строку в журнал ниже.
- Делает финальный коммит фазы.
- Явно говорит: «Фаза N закрыта — можно
/clear, следующая фаза N+1 на модели X».
Текущее состояние
- Текущая фаза: 4 ✅ закрыта → следующая Фаза 5 (полная конфигурация Postfix — исходящий релей)
- Модель для Фазы 5: Opus (самый чувствительный тракт доставки: SASL/TLS на 465,
reject_sender_login_mismatch, отсутствие open relay) - Статус: приложения + SASL + привязка к домену реализованы и проверены на сервере (
gofmt/go vet/go testзелёные, docker-образ собирается; контейнерный e2e: создание wildcard/list-приложений с показом пароля один раз, валидация (чужой домен → 400, дубль логина → 409,@в логине → 400), учётки вsasldb2под realm, генерацияsmtpd_sender_login_mapsс many-to-one слиянием логинов, перевыпуск пароля, смена режима, удаление приложения, каскадное удаление домена с очисткойsasldb2и пересборкой карты, персистентностьsasldb2+карты через рестарт с самолечением прав, реальныйpostfix reloadчерез одноразовую supervisord-программу) - Следующий шаг (Фаза 5):
master.cf—smtps465 (wrapper TLS) как основной, опциональноsubmission587 (STARTTLS,smtpd_tls_auth_only=yes);main.cf—smtpd_sasl_auth_enable,smtpd_sasl_type=cyrus+smtpd/saslconf, указывающий на/data/sasl/sasldb2;smtpd_sender_login_maps = texthash:/data/postfix/sender_login_maps(файл уже генерируется панелью в Фазе 4) +reject_sender_login_mismatchвsmtpd_sender_restrictions;smtpd_recipient_restrictions(permit_sasl_authenticated,reject_unauth_destination), никакого open relay; TLS cert/key изTLS_CERT_FILE/TLS_KEY_FILE(read-only mount) + периодический reload; исходящая доставка (MX-lookup,smtp_tls_security_level=may); rate-limit уровня 1 (anvil); milter-цепочка OpenDKIM + journal (fail-open). Критично для Фазы 5: определиться с реалмом SASL — панель создаёт учётки под realmSELFPOST_HOSTNAME(см.SASL_REALM); значение в карте — «голый» логин. Настроить Postfix так, чтобыsasl_usernameсовпадал со значением карты (smtpd_sasl_local_domainпустой ИЛИ подгонка realm), и проверить реальной отправкой. Также решить chroot дляsmtpd:/data/sasl/sasldb2вне/var/spool/postfix— либо отключить chroot для сервиса приёма, либо иначе обеспечить доступ.
Сделано в Фазе 4
- Модель приложения (
internal/store/applications.go): CRUD надapplications/application_addressesв транзакциях;loginглобально уникален (арбитр — UNIQUE,ErrLoginExists);ListBindings(SQLUNION ALL) отдаёт пары «адрес→логин» (wildcard →@domain, list → каждый адрес) детерминированно отсортированными — сырьё для карты;ListLoginsByDomainдля очисткиsasldb2перед каскадом;DeleteApplicationвозвращает удалённую строку (нужен логин дляsasldb2). - SASL
sasldb2(internal/app/sasl.go): обёртка надsaslpasswd2(эквивалент по ТЗ 5.1).Set=-p -c -f <db> -u <realm> <login>, пароль только через stdin (не в argv → не течёт вps/логи), логин — отдельный argv-элемент после строгого whitelist (validateLogin, без@— это разделитель realm вsasldb2), без shell (ТЗ 7.6.3).Delete=-d ..., идемпотентно.run— инъектируемое поле для тестов. - Пароль приложения (
internal/app/password.go): 24 байтаcrypto/rand→ base64url (192 бита), генерируется панелью, показывается один раз, plaintext не хранится (ТЗ 7.6.1). - Валидация (
internal/app/validate.go):validateLogin(3–64,[A-Za-z0-9._-], без@);validateSenderAddress— критичная проверка ТЗ 7.6.2: часть после@обязана строго равняться домену приложения (проверка ДО записи в конфиг, а не черезsender_login_mapsпри доставке); строгий whitelist localpart;parseAddressesнормализует/дедуплицирует/требует ≥1 адрес. smtpd_sender_login_maps(internal/postfix/postfix.go): полная регенерация файла из всех привязок (чистая функция реестра, идемпотентно, как OpenDKIM-таблицы), атомарная запись (write.go, temp+rename). Many-to-one: несколько логинов на один адрес сливаются в одну строкуадрес log1,log2(штатный случай ТЗ 5.1 §4).assertMapSafe— backstop против пробелов/переводов строк/запятых/@в логине (ТЗ 7.6.4). Тип карты для Фазы 5 —texthash:(безpostmap).- Reload Postfix — исправленный механизм (ключевое инфра-решение): изначальный план «
supervisorctl signal HUP postfix» не работает —postfix start-fgфоркает отдельный master, и сигнал супервизируемому foreground-процессу до master не доходит (в отличие от OpenDKIM, который сам и есть foreground-процесс). Решение: одноразовая supervisord-программа[program:postfix-reload](command=/usr/sbin/postfix reload,autostart=false,startsecs=0,exitcodes=0); панель дёргает еёsupervisorctl start postfix-reloadчерез тот же групповой контрол-сокет. Даёт настоящийpostfix reloadот root без привилегий у панели; postfix остаётся RUNNING, программа уходит в EXITED(0), crashexit не срабатывает. Проверено поmail.log(reload -- version). - Сервис приложений (
internal/app/service.go): координация store+sasldb2+карты.Create— сначала строка реестра (арбитр дубля, не даёт затереть чужой пароль вsasldb2), затемsasldb2, затем rebuild карты + reload; полный откат при сбое любого шага.UpdateMode/Delete/RegeneratePassword/PurgeDomainSASL/Resync.SenderMaps— интерфейс над Postfix для тестируемости. - Интеграция с доменами (
internal/domain/service.go):Deleteтеперь через интерфейсApplications— сначалаPurgeDomainSASL(пока логины в реестре), затем каскад БД, затем rebuild карты + reload Postfix, затем удаление DKIM-ключа. Ручной reload (7.2.12) теперь перегружает и OpenDKIM, и Postfix. - Web (
internal/web/handlers_apps.go, шаблонdomain_detail.html): создание/редактирование режима/перевыпуск/удаление приложения в карточке домена; одноразовый показ пароля рендерится инлайн (не через redirect — иначе пароль потерян); серверные ошибки валидации показываются на форме; флаги подтверждения на удаление/перевыпуск. - Инфра:
postfixдобавлен в группуselfpost(Dockerfile) для чтенияsasldb2/карты;entrypoint.shнормализует/data/sasl(setgid 2750,sasldb20640) и/data/postfix(файл карты создаётся пустым до старта, 0640) — самолечение прав после restore. - Проверено на сервере (selfpost.mixfed.ru):
gofmt/go vet/go testзелёные (юниты: store CRUD/bindings/cascade, рендер карты+инъекции, валидация логина/адреса/принадлежности домену, argv/stdinsaslpasswd2, сервис create/rollback/delete/mode/regen/purge с фейками); docker build ок; контейнерный e2e — весь жизненный цикл приложения + каскад домена + персистентность через рестарт + реальныйpostfix reloadиз пути создания и кнопки reload.
Сделано в Фазе 3
- Per-domain DKIM в чистом Go (
internal/domain/dkim.go): RSA-2048 черезcrypto/rsa, приватный ключ PKCS#1 PEM пишется атомарно (temp+rename,writeFileAtomic) с mode 0640; DNS TXT-запись (v=DKIM1; h=sha256; k=rsa; p=<base64 PKIX DER>) вычисляется из ключа на лету — ключ на диске = единственный источник истины.opendkim-genkeyне используется (никакого exec для keygen). - OpenDKIM KeyTable/SigningTable (
internal/domain/opendkim.go): полная регенерация обеих таблиц из реестра при каждом add/delete (идемпотентно), атомарная запись;KeyTable— абсолютный путь к ключу (OpenDKIM резолвит относительные от CWD — проверено),SigningTableчерезrefile:с шаблоном*@domain.assertConfigSafe(backstop 7.6.4) отклоняет пробелы/переводы строк/://в имени/селекторе перед записью. - Reload OpenDKIM без root (ключевое инфра-решение): панель (uid 999,
panel) не может сигналить процесс opendkim (uid 100) напрямую. Reload =supervisorctl signal USR1 opendkim(exec, фикс-аргументы, без shell/ввода — 7.6.3); контрол-сокет supervisord открыт группе (chown=root:selfpost chmod=0770),panelв группеselfpost.ExecReloadopendkim =kill -USR1(SIGUSR1 перечитывает таблицы) — подтверждено. - Межпользовательский доступ к ключам: общая группа
selfpost(в неё добавленыpanelиopendkim); дерево/data/opendkimс setgid (mode 2750) → файлы, созданные панелью, наследуют группуselfpost; ключи 0640 → opendkim читает по группе.RequireSafeKeys no(ключи group-readable — безопасно на приватном single-tenant bind-mount).entrypoint.shнормализует дерево на каждом старте (group/setgid/пермишены + пустые KeyTable/SigningTable до старта opendkim), самолечение после restore.opendkim.confпереведён Modev→s. - Сервис/веб (
internal/domain/service.go,internal/store/domains.go,internal/web/handlers_domains.go): Add (реестр→ключ→rebuild→reload, откат строки при сбое; существующий ключ переиспользуется, не перегенерируется — иначе сломается опубликованный DNS), Delete (каскад приложений через FKON DELETE CASCADE+ удаление ключа + rebuild+reload), список на дашборде со счётчиком приложений, карточка домена с TXT-записью (7.2.10), отдельная страница подтверждения удаления с предупреждением о каскаде (7.2.4), ручной Reload (7.2.12, пока только OpenDKIM — Postfix в Фазе 5). Валидация имени домена — строгий whitelist[a-z0-9.-], DNS-форма, ≥2 меток (7.6.2), нормализация в lower-case. Роутинг переведён на authenticated под-mux с method+wildcard-паттернами Go 1.22. - Проверено на сервере (selfpost.mixfed.ru):
gofmt/go vet/go test(юниты: validateDomain, DKIM keygen/record roundtrip, renderTables + injection-safety, EnsureKey reuse, store cascade) зелёные;docker buildок; контейнерный e2e (curl+сессия): три процесса живы, addExample.COM→нормализация→ключ+таблицы+TXT, opendkim читает ключ панели и остаётся RUNNING после reload, рестарт → ключ и таблицы персистентны (хэш совпал), delete → каскад, удаление ключа, пустые таблицы, opendkim reload; лог панели без ошибок.
Сделано в Фазе 2
- SQLite-персистентность (
internal/store): драйверmodernc.org/sqlite(чистый Go, без cgo — статик-бинарник сохранён; первые сторонние зависимости → появилисьgo.modrequire +go.sum), WAL +foreign_keys(ON)+busy_timeoutчерез DSN_pragma,MaxOpenConns(1). Встроенные (embed) нумерованные миграции с версионированием черезPRAGMA user_version; миграция0001_init.sqlзаводит всю схему ТЗ 9:admin(одна строка,CHECK id=1),settings,domains,applications,application_addresses,send_log(+индексы),rate_limits. ЗапросыAdminExists/CreateAdmin/GetAdmin. - Setup secret-link (
internal/web/setup.go, ТЗ 7.6.1): токен 128 бит изcrypto/rand(base64url), ссылкаhttps://<SELFPOST_HOSTNAME>/setup/<token>печатается в лог и пишется в/data/setup-token(0600); TTL 10 мин с перегенерацией при истечении/рестарте (пока нет админа); сравнениеsubtle.ConstantTimeCompare; неудачи НЕ инвалидируют токен; «настройка завершена» = наличие строкиadmin(источник истины), поэтому после успеха токен сгорает навсегда и весь/setup/*→ 404. Форма создания админа — одноразовая; гонка двух POST безопасна (CHECK id=1+ проверка существования). - Пароль админа — только
bcrypt(golang.org/x/crypto/bcrypt, DefaultCost); серверная валидация (username whitelist 7.6.2, пароль ≥12). - Логин + сессии (
internal/web/handlers_auth.go,session.go): вход сверяет username + bcrypt (bcrypt считается всегда — timing-инвариантно), сессии в памяти (не в списке ТЗ 9 на персист — рестарт просто разлогинивает), крипто-токен 256 бит; cookieHttpOnly/Secure/SameSite=Lax(Secureпо умолчанию, отключаетсяPANEL_COOKIE_SECURE=falseтолько для dev-HTTP); rate-limit логина (ratelimit.go, 10/15мин по IP) и отдельный на/setup(10/мин); auth-middleware защищает панель. - Front-end: базовый layout
html/template(автоэкранирование, 7.6.7) + страницы setup/login/dashboard (embed); вендоренныйhtmx.min.js2.0.4 (internal/web/static, отдаётся с/static/). - Entrypoint для bind-mount (
build/entrypoint.sh):/data— host bind mount → приходит от root, а панель работает под непривилегированнымpanel(uid 999). Точка входа под root чинит владельца/dataпередexec supervisord(иначе SQLiteunable to open database file). Найдено и исправлено при контейнерной проверке. - Проверено на сервере (selfpost.mixfed.ru):
go vet/go build/go test/gofmt -lчисто; функциональный e2e (curl): setup-ссылка печатается+в файл,GET /setup/<token>→200, неверный/просроченный→404,POST /setupсоздаёт админа→303, повторный/setup→404, файл токена удалён; плохой логин→401, хороший→303 с cookieHttpOnly; Secure; SameSite=Lax,/→200, logout→303, после logout/→303; рестарт с существующим админом не печатает setup. Docker-образ собирается; контейнер с-v ./data:/data: три процесса, БД+токен создаются подpanel, токен 0600.
Сделано в Фазе 1
- Образ
build/Dockerfile(bookworm-slim): многостадийная статическая сборка Go (CGO_ENABLED=0,go vetв сборке); рантайм — postfix, opendkim(+tools), cyrus-sasl (sasl2-bin,libsasl2-modules), supervisor, logrotate, ca-certificates;maillog_file=/var/log/mail.log; непривилегированный пользовательpanel(ТЗ 7.6.8);.dockerignore(dev/ и docs/ не попадают в контекст). - supervisord (
build/supervisord.conf,nodaemon, PID 1): порядокpriority=opendkim(100)→panel(200)→postfix(300); event-listenercrashexit.pyнаPROCESS_STATE_FATALшлёт SIGTERM супервизору → контейнер завершается при неперезапускаемом падении (ТЗ 4). - Обёртка Postfix (
build/postfix-wrapper.sh): опросtest -Sобоих milter-сокетов (OpenDKIM/run/opendkim/opendkim.sock+ journal/run/selfpost/journal.sock), таймаут 30с, при неготовности — выход ≠0 без запуска Postfix; иначеexec postfix start-fg(решает только холодный старт, ТЗ 4). - Панель (
cmd/panel, три роли под общим ctx + graceful shutdown по SIGTERM): HTTP:8080заглушка +/healthz; stub journal-milter — открывает unix-сокет, чтобы обёртка проходила (реальный milter — Фаза 6); stub log-tailer (реальный хвост — Фаза 6).opendkim.confвременно Modev(без ключей; Modes+KeyTable — Фаза 3). - Проверено на сервере (selfpost.mixfed.ru):
docker buildок;docker run→ три живых процесса (opendkim, panel@non-root, postfix master); панель/→200 и/healthz→ok; обёртка дождалась сокетов; форсированный FATAL панели → crashexit → контейнер завершился чисто; SIGTERM → «panel stopped cleanly». Образ 334MB, тегselfpost:devоставлен на сервере.
Сделано в Фазе 0
- Go-модуль
codeberg.org/mix/selfpost, каркасcmd/panel+cmd/selfpost-backup, общийinternal/buildinfo(версия через ldflags). Makefile(статическая сборкаCGO_ENABLED=0),LICENSE(полный AGPL-3.0), скелетREADME.md,.gitattributes(LF).- Проверено на сервере:
go vetчист,make buildдаёт статические бинарники, версия впечатывается. - Спайк go-milter (де-риск ТЗ 7.3): v0.4.1 (BSD-2) ↔ Postfix 3.7.11 bookworm, протокол v6. Подтверждено чтение From/To(per-rcpt)/Subject/queue-id, client IP из Connect(), fail-open при падении milter'а. Детали:
dev/spike-milter-notes.md. - Провижён dev-сервер: Go 1.26.5, Docker 29.6.1, git, rsync, make. Host-postfix (спайковый) остановлен+отключён.
Рабочая петля (dev loop) — ВАЖНО
Локально (Windows, D:\Local\Git\selfpost) нет Go и Docker — только редактирование и git. Вся сборка/тесты идут на dev-сервере selfpost.mixfed.ru (Debian 12 bookworm, тот же, что базовый образ; провижён под разработку). Цикл: править локально → rsync дерева на сервер → go build/go vet/docker build/тесты там. Источник истины и git-история — локальный репозиторий; сервер — только исполнитель сборки/тестов. Подключение: ssh root@selfpost.mixfed.ru (по ключу).
Утверждённые решения
- Module path:
codeberg.org/mix/selfpost - SQLite:
modernc.org/sqlite(чистый Go, без cgo) - milter:
github.com/emersion/go-milter(проверить совместимость в Фазе 0) - Структура: стандартная Go-раскладка (
cmd/,internal/) - Front-end:
html/template+ вендоренный HTMX - Коммиты — только по явной команде пользователя (ТЗ 12.1)
Журнал фаз
- Фаза 0 (2026-07-11, Opus) — каркас проекта + build-пайплайн + спайк go-milter (риск ТЗ 7.3 снят). Коммиты
4e589e1(каркас),87388b4(план). - Фаза 1 (2026-07-11, Opus) — Docker-образ + supervisord + три процесса, холодный старт (обёртка ждёт milter-сокеты) и crashexit проверены на сервере. Коммит
ed9e942. - Фаза 2 (2026-07-11, Opus) — SQLite (
modernc.org/sqlite, миграции, схема ТЗ 9), setup secret-link (128-бит токен, TTL 10м, const-time, одноразово), bcrypt-админ, логин/сессии/cookie-флаги, rate-limit setup+логина, html/template + вендоренный HTMX, auth-middleware; entrypoint чинит владельца bind-mount/data. Проверено на сервере (e2e curl + docker run). - Фаза 3 (2026-07-11, Opus) — домены + per-domain DKIM: keygen в чистом Go (RSA-2048, PKCS#1 PEM, TXT из ключа), OpenDKIM KeyTable/SigningTable (Mode
s,refile:) с полной регенерацией, reload черезsupervisorctl signal USR1 opendkim(сокет открыт группеselfpost, панель без root — 7.6.3/7.6.8), межпользовательский доступ к ключам через общую группуselfpost+setgid+RequireSafeKeys no, add/delete/список/TXT/подтверждение каскада/ручной reload, строгая валидация имени домена (7.6.2), injection-safe запись таблиц (7.6.4). Юнит-тесты + контейнерный e2e (add/delete, персистентность ключей через рестарт, reload) зелёные. Проверено на сервере. - Фаза 4 (2026-07-12, Opus) — приложения + SASL + привязка к домену: учётки в
sasldb2черезsaslpasswd2(пароль по stdin, логин whitelisted argv, без shell — 7.6.3), генерируемый пароль показывается один раз (7.6.1), режим адресов wildcard/list с серверной проверкой принадлежности адреса домену (7.6.2), генерацияsmtpd_sender_login_maps(many-to-one слияние, injection-safe — 7.6.4), CRUD приложений + перевыпуск пароля + каскад при удалении домена (очисткаsasldb2+ пересборка карты). Исправлен reload Postfix:signal HUPне доходит до форкнутого master → одноразовая supervisord-программаpostfix-reload(настоящийpostfix reloadот root без привилегий панели).postfixв группеselfpost,/data/sasl+/data/postfixпод setgid. Новые пакетыinternal/app,internal/postfix. Юнит-тесты + контейнерный e2e (весь жизненный цикл, каскад, персистентность, реальный reload поmail.log) зелёные.