diff --git a/CHANGELOG.md b/CHANGELOG.md index 69b7a63..0b3f3a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ### Fixed +- A bounce could be recorded as a successful delivery. The log-tailer's + delivery-line pattern matched `status=` greedily, so it took the *last* + occurrence on the line — and Postfix appends the remote server's reply + verbatim, which the far end controls. A rejection whose reply text contained + `status=sent` was filed as `sent` in the send log. The pattern now takes the + first `status=` after the recipient, which is the real field + (`internal/logtail/logtail.go`); found while extending `TestParseDelivery`. - Log-tailer resumes where it stopped instead of jumping to end-of-file on every start (phase 3, `docs/code-review.md`): the read position and a fingerprint of the log's head are persisted (`logtail_state`, migration @@ -24,6 +31,23 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ### Changed +- The project has a single public home: `github.com/mixeme/selfpost`. Codeberg + is being retired, so the Go module path moved with it — `go.mod`, + `test/e2e/go.mod`, every import, the `Makefile` `MODULE` variable and the + `-ldflags` version stamp in `build/Dockerfile` and `docs/development.md`. An + import path pointing at a host that is going away would break `go get` and + `go install` outright, which is why this is not only a documentation change. + README no longer lists a primary/mirror pair. +- Code comments no longer cite the archived specification. References like + "spec 7.6.1" or "spec 5.1" pointed into `docs/archive/specification-v1.0.md`, + which is explicitly not a source of truth; each is now a reference to the + live document that owns the subject — `docs/architecture.md` (with section), + `docs/product.md`, `docs/security.md`, or the README. Comments only; no + behaviour is affected. +- `docs/architecture.md` gained a *Code layers* section: a diagram of + handlers → services → store plus the adapters, and the reason the services + layer exists (multi-store writes and their rollback) — closing item A2 of + `docs/code-review.md`. - Phase 1 doc/code hygiene (`docs/code-review.md`): removed ~30 stale "Phase N" / historical-staging references from code and shell-script comments (`cmd/panel`, `internal/*`, `build/*`) now that v1.0 is done; @@ -32,6 +56,15 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version `docs/security.md` (why Origin-check, not tokens); resolved `docs/logo` in `docs/roadmap.md` (directory doesn't exist, criterion already met); added a `gofmt -l` check to CI (`.github/workflows/test.yml`). +- Phase 2 GUI polish (`docs/code-review.md`): the monitoring pages stop + polling while their tab is hidden — the skip is done in an + `htmx:beforeRequest` listener (`internal/web/static/panel.js`) rather than + htmx's own trigger filter, which is evaluated with `new Function` and would + be blocked by the panel's CSP. Dark mode is now a single reassignment of CSS + custom properties under `prefers-color-scheme: dark` instead of a cascade of + `!important` overrides, and the duplicate `main { max-width }` rule is + consolidated into one base rule with documented per-page overrides + (`internal/web/static/panel.css`). ### Security @@ -60,6 +93,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version `-decrypt` (with `-i`/`-o`) turns a `.spbk` into the plain `.tar.gz` a restore unpacks. The password comes from `SELFPOST_BACKUP_PASSWORD` or `-password-file`, never from argv. +- `TestParseDelivery` covers the exotic mail.log shapes the review asked for + (`docs/code-review.md` § 3): a `status=` quoted inside the remote reply, the + null recipient of a double bounce, `orig_to=` alongside `to=`, an + unrecognised status word, a capitalised one, and a cleanup line. - docs: README *Encrypting a backup or export*; `docs/security.md` § *Резервная копия и экспорт домена* + accepted risk (encryption is opt-in); `docs/architecture.md` persistence § envelope summary. diff --git a/Makefile b/Makefile index 5ac55d0..491605c 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ # Go, so no cgo is required) — see spec 7.1. VERSION ?= dev -MODULE := codeberg.org/mix/selfpost +MODULE := github.com/mixeme/selfpost LDFLAGS := -X $(MODULE)/internal/buildinfo.Version=$(VERSION) GOFLAGS := -trimpath diff --git a/README.md b/README.md index d3481e9..51c8ff8 100644 --- a/README.md +++ b/README.md @@ -364,8 +364,8 @@ swap file is cheap insurance against those occasional coincident spikes. ## Repository -- Primary: -- Mirror: + — the single home of the project: source, +issues, releases and the `ghcr.io/mixeme/selfpost` image all live there. ## License diff --git a/build/Dockerfile b/build/Dockerfile index cb43760..7aad6f9 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -23,10 +23,10 @@ COPY internal ./internal ENV CGO_ENABLED=0 RUN go vet ./... \ && go build -trimpath \ - -ldflags "-X codeberg.org/mix/selfpost/internal/buildinfo.Version=${VERSION}" \ + -ldflags "-X github.com/mixeme/selfpost/internal/buildinfo.Version=${VERSION}" \ -o /out/panel ./cmd/panel \ && go build -trimpath \ - -ldflags "-X codeberg.org/mix/selfpost/internal/buildinfo.Version=${VERSION}" \ + -ldflags "-X github.com/mixeme/selfpost/internal/buildinfo.Version=${VERSION}" \ -o /out/selfpost-backup ./cmd/selfpost-backup # ---- runtime stage ----------------------------------------------------------- diff --git a/cmd/panel/httpserver.go b/cmd/panel/httpserver.go index 6faf1d3..39bc84e 100644 --- a/cmd/panel/httpserver.go +++ b/cmd/panel/httpserver.go @@ -7,17 +7,17 @@ import ( "net/http" "time" - "codeberg.org/mix/selfpost/internal/app" - "codeberg.org/mix/selfpost/internal/buildinfo" - "codeberg.org/mix/selfpost/internal/domain" - "codeberg.org/mix/selfpost/internal/postfix" - "codeberg.org/mix/selfpost/internal/store" - "codeberg.org/mix/selfpost/internal/web" + "github.com/mixeme/selfpost/internal/app" + "github.com/mixeme/selfpost/internal/buildinfo" + "github.com/mixeme/selfpost/internal/domain" + "github.com/mixeme/selfpost/internal/postfix" + "github.com/mixeme/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/web" ) // serveHTTP runs the control-panel HTTP server until ctx is cancelled, using // the database handle shared by all roles: setup, login and the authenticated -// panel surface (spec 7.6). +// panel surface (security.md). func serveHTTP(ctx context.Context, cfg config, st *store.Store) error { // Applications own the SASL accounts and the Postfix sender map; the domain // service delegates to them when a domain (and its applications) is deleted. diff --git a/cmd/panel/journal.go b/cmd/panel/journal.go index e15dbdc..bc206b9 100644 --- a/cmd/panel/journal.go +++ b/cmd/panel/journal.go @@ -8,14 +8,14 @@ import ( "os" "path/filepath" - "codeberg.org/mix/selfpost/internal/milter" - "codeberg.org/mix/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/milter" + "github.com/mixeme/selfpost/internal/store" ) // serveJournal opens the journal-milter Unix socket and runs the real milter -// (spec 7.3), recording accepted messages into the send log. Socket lifecycle -// (creation, stale cleanup, group permissions) lives here; the protocol handler -// lives in internal/milter. +// (architecture.md § Mail path), recording accepted messages into the send +// log. Socket lifecycle (creation, stale cleanup, group permissions) lives +// here; the protocol handler lives in internal/milter. func serveJournal(ctx context.Context, cfg config, st *store.Store) error { socketPath := cfg.journalSocket if err := os.MkdirAll(filepath.Dir(socketPath), 0o755); err != nil { diff --git a/cmd/panel/main.go b/cmd/panel/main.go index 61efdff..a2ea695 100644 --- a/cmd/panel/main.go +++ b/cmd/panel/main.go @@ -1,6 +1,7 @@ // Command panel is the SelfPost control panel. This single binary combines -// several roles (spec 7.1) as a supervised process: the HTTP panel server, -// the journal-milter, the mail.log tailer and the rate-limit checks. +// several roles (architecture.md § Image and processes) as a supervised +// process: the HTTP panel server, the journal-milter, the mail.log tailer and +// the rate-limit checks. package main import ( @@ -17,11 +18,11 @@ import ( "sync" "syscall" - "codeberg.org/mix/selfpost/internal/backup" - "codeberg.org/mix/selfpost/internal/buildinfo" - "codeberg.org/mix/selfpost/internal/dnscheck" - "codeberg.org/mix/selfpost/internal/logtail" - "codeberg.org/mix/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/backup" + "github.com/mixeme/selfpost/internal/buildinfo" + "github.com/mixeme/selfpost/internal/dnscheck" + "github.com/mixeme/selfpost/internal/logtail" + "github.com/mixeme/selfpost/internal/store" ) func main() { @@ -81,8 +82,9 @@ func loadConfig() config { httpAddr: envDefault("PANEL_HTTP_ADDR", ":8080"), journalSocket: envDefault("JOURNAL_MILTER_SOCKET", "/run/selfpost/journal.sock"), mailLog: envDefault("MAIL_LOG", "/var/log/mail.log"), - // Send-log retention window (spec 7.3). Non-positive/invalid falls back - // to the 90-day default inside the log-tailer. + // Send-log retention window (architecture.md § Persistence). + // Non-positive/invalid falls back to the 90-day default inside the + // log-tailer. retentionDays: envInt("SEND_LOG_RETENTION_DAYS", 90), dataDir: dataDir, @@ -90,7 +92,7 @@ func loadConfig() config { manifestPath: filepath.Join(dataDir, backup.ManifestName), setupTokenPath: envDefault("SELFPOST_SETUP_TOKEN_FILE", filepath.Join(dataDir, "setup-token")), hostname: os.Getenv("SELFPOST_HOSTNAME"), - // Secure cookies by default (spec 7.6.6); PANEL_COOKIE_SECURE=false is a + // Secure cookies by default (security.md); PANEL_COOKIE_SECURE=false is a // development-only escape hatch for testing over plain HTTP. cookieSecure: envDefault("PANEL_COOKIE_SECURE", "true") != "false", // Whether this deployment also runs the 587 submission listener. The @@ -102,7 +104,7 @@ func loadConfig() config { // XFF header is trivially forgeable, so it's ignored unless the panel is // told which proxy to trust. trustedProxies: parseTrustedProxies(os.Getenv("TRUSTED_PROXY_CIDR")), - // Sliding session idle timeout (spec 7.6.6, plan B.1). Non-positive/invalid + // Sliding session idle timeout (security.md, plan B.1). Non-positive/invalid // falls back to the 7-day default inside internal/web. sessionIdleDays: envInt("PANEL_SESSION_IDLE_DAYS", 7), // Recursive resolvers the deliverability checks query directly. Empty @@ -112,15 +114,16 @@ func loadConfig() config { tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"), opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"), - // Per-domain DKIM state (spec 6). The directory layout matches what - // entrypoint.sh prepares (setgid, shared `selfpost` group). + // Per-domain DKIM state (architecture.md § OpenDKIM). The directory layout + // matches what entrypoint.sh prepares (setgid, shared `selfpost` group). opendkimDir: envDefault("OPENDKIM_DIR", filepath.Join(dataDir, "opendkim")), dkimSelectorDef: envDefault("DKIM_SELECTOR_DEFAULT", "selfpost"), - // Application SASL accounts and the Postfix sender map (spec 5.1, 9), - // both under /data so they survive restarts. The SASL realm defaults to - // the server hostname so account identities line up with Postfix's SASL - // configuration; it falls back to localhost outside the container. + // Application SASL accounts and the Postfix sender map (architecture.md § + // Mail path), both under /data so they survive restarts. The SASL realm + // defaults to the server hostname so account identities line up with + // Postfix's SASL configuration; it falls back to localhost outside the + // container. saslDBPath: envDefault("SASL_DB_PATH", filepath.Join(dataDir, "sasl", "sasldb2")), saslRealm: saslRealm(), postfixDir: envDefault("POSTFIX_DIR", filepath.Join(dataDir, "postfix")), @@ -191,9 +194,10 @@ func parseTrustedProxies(raw string) []*net.IPNet { } // run starts the panel's three roles and blocks until a shutdown signal or the -// first fatal error from any role. A signal triggers a clean stop of all roles; -// a role error cancels the others and is returned so the process exits non-zero -// (letting supervisord/Docker see the failure — spec 4). +// first fatal error from any role. A signal triggers a clean stop of all +// roles; a role error cancels the others and is returned so the process exits +// non-zero (letting supervisord/Docker see the failure — architecture.md § +// Image and processes). func run() error { cfg := loadConfig() @@ -202,10 +206,11 @@ func run() error { log.Printf("starting selfpost panel %s", buildinfo.Version) - // Restore version guard (spec 7.5.A): if a backup was extracted into /data, - // its manifest version must match this binary before we touch the database, - // so schema/format skew between versions cannot corrupt the restored state. - // A match consumes the manifest; its absence is the normal (non-restore) case. + // Restore version guard (architecture.md § Persistence): if a backup was + // extracted into /data, its manifest version must match this binary before we + // touch the database, so schema/format skew between versions cannot corrupt + // the restored state. A match consumes the manifest; its absence is the + // normal (non-restore) case. if err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version); err != nil { return err } diff --git a/cmd/selfpost-backup/main.go b/cmd/selfpost-backup/main.go index c90858b..829bf9b 100644 --- a/cmd/selfpost-backup/main.go +++ b/cmd/selfpost-backup/main.go @@ -1,6 +1,6 @@ // Command selfpost-backup produces the full persistent-state archive from inside // the container, invoked via `docker exec` for scripted/cron backups — the CLI -// equivalent of the panel's backup button (spec 7.5.A, 11.6). +// equivalent of the panel's backup button (architecture.md § Persistence). // // By default the gzip-compressed tar is written to stdout, so the usual form is: // @@ -8,7 +8,7 @@ // // Use -o to write to a file instead. The resulting archive contains DKIM private // keys, the admin password hash and SASL credentials — treat it as a secret -// (spec 7.5.A). +// (architecture.md § Persistence). // // Given a password (SELFPOST_BACKUP_PASSWORD or -password-file, never an // argument, which would show up in the process list) the archive is written as @@ -26,9 +26,9 @@ import ( "path/filepath" "strings" - "codeberg.org/mix/selfpost/internal/backup" - "codeberg.org/mix/selfpost/internal/buildinfo" - "codeberg.org/mix/selfpost/internal/secretfile" + "github.com/mixeme/selfpost/internal/backup" + "github.com/mixeme/selfpost/internal/buildinfo" + "github.com/mixeme/selfpost/internal/secretfile" ) // passwordEnv names the environment variable holding the encryption password. diff --git a/cmd/selfpost-backup/main_test.go b/cmd/selfpost-backup/main_test.go index bd2780b..f6c2cf7 100644 --- a/cmd/selfpost-backup/main_test.go +++ b/cmd/selfpost-backup/main_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "codeberg.org/mix/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/store" ) // seedDataDir builds the minimum /data tree a backup can be taken from. diff --git a/docs/architecture.md b/docs/architecture.md index 0d05215..750064f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -166,6 +166,67 @@ cookie and idle timeout has not expired. --- +## Code layers + +Handlers never touch SQLite or the filesystem directly; every write that has to +land in more than one place (SQLite row, `sasldb2` entry, Postfix map, OpenDKIM +table) goes through a service, which is also where the rollback of a partial +failure lives. The adapters below the services are the only code that knows +about Postfix, OpenDKIM, DNS or the log file, which is what makes them +substitutable in tests — `milter.Store`, `app.SenderMaps` and +`logtail.StatusStore` are the seams the unit tests replace with fakes. + +```mermaid +flowchart TB + subgraph cmd ["cmd — composition root"] + panel["panel: HTTP + journal-milter + log-tailer"] + backupcli["selfpost-backup CLI"] + end + subgraph web ["internal/web — HTTP surface"] + handlers["handlers_*.go, templates, session/security"] + end + subgraph services ["Services — multi-store operations + rollback"] + domainSvc["internal/domain"] + appSvc["internal/app"] + end + subgraph persistence ["Persistence"] + store["internal/store — SQLite, embedded migrations"] + end + subgraph adapters ["Adapters — the only infrastructure-aware code"] + postfix["internal/postfix"] + milterPkg["internal/milter"] + logtail["internal/logtail"] + dnscheck["internal/dnscheck"] + backupPkg["internal/backup"] + health["internal/health"] + secretfile["internal/secretfile"] + end + panel --> web + panel --> milterPkg + panel --> logtail + backupcli --> backupPkg + backupcli --> secretfile + web --> domainSvc + web --> appSvc + web --> backupPkg + web --> dnscheck + web --> health + web --> secretfile + domainSvc --> store + appSvc --> store + milterPkg --> store + logtail --> store + domainSvc --> postfix + appSvc --> postfix +``` + +The three roles inside the `panel` process (HTTP server, journal-milter, +log-tailer goroutine) share one binary and one SQLite handle on purpose — see +[Panel binary](#panel-binary-cmdpanel) for why, and *Persistence* below for the +single-connection trade-off that follows from it. + +--- + ## Persistence (`/data` bind mount) | Path | Contents | diff --git a/docs/assets/selfpost-icon-16.svg b/docs/assets/selfpost-icon-16.svg index 9e714db..f197cf3 100644 --- a/docs/assets/selfpost-icon-16.svg +++ b/docs/assets/selfpost-icon-16.svg @@ -1,5 +1,5 @@ - diff --git a/docs/assets/selfpost-icon.svg b/docs/assets/selfpost-icon.svg index 8b29d72..c724436 100644 --- a/docs/assets/selfpost-icon.svg +++ b/docs/assets/selfpost-icon.svg @@ -1,5 +1,5 @@ - diff --git a/docs/assets/selfpost-proof.html b/docs/assets/selfpost-proof.html index 757dd0e..e885a15 100644 --- a/docs/assets/selfpost-proof.html +++ b/docs/assets/selfpost-proof.html @@ -89,7 +89,7 @@

Эталон

- @@ -112,7 +112,7 @@
mixeme / selfpost
- @@ -126,7 +126,7 @@
mixeme / selfpost
- @@ -144,7 +144,7 @@

Шапка панели

- @@ -166,14 +166,14 @@
- SP SelfPost — Domains
- @@ -186,11 +186,11 @@
- - - + + +
-
80 / 48 / 32 px
Аватар организации на Codeberg и GitHub. Порог перехода на версию SP — 32 px.
+
80 / 48 / 32 px
Аватар организации на GitHub. Порог перехода на версию SP — 32 px.
@@ -199,7 +199,7 @@

Размеры и охранное поле

- @@ -208,14 +208,14 @@ SELF-HOSTED SMTP RELAY полная · от 280 px
- SelfPost компактная · от 100 px
- diff --git a/docs/assets/selfpost-stamp-compact.svg b/docs/assets/selfpost-stamp-compact.svg index 3c03928..9676b21 100644 --- a/docs/assets/selfpost-stamp-compact.svg +++ b/docs/assets/selfpost-stamp-compact.svg @@ -1,5 +1,5 @@ - diff --git a/docs/assets/selfpost-stamp.svg b/docs/assets/selfpost-stamp.svg index cb46ba3..3054766 100644 --- a/docs/assets/selfpost-stamp.svg +++ b/docs/assets/selfpost-stamp.svg @@ -1,5 +1,5 @@ - diff --git a/docs/code-review.md b/docs/code-review.md index f96406b..88d5d9e 100644 --- a/docs/code-review.md +++ b/docs/code-review.md @@ -22,6 +22,8 @@ **Вывод:** проект готов к релизному тегу после закрытия § D ([implementation-plan.md](implementation-plan.md)) — предрелизного security review. Остальное — polish, не блокеры. +**Статус на 2026-08-06:** § D закрыт, фазы 1, 1.5, 2 и 3 выполнены, добор по §§ 1/3/4 сделан. Незакрытым остаётся только то, что делается в момент резки версии: бамп тега образа в compose и сам git-тег ([roadmap.md](roadmap.md) § v1.x). + --- ## 1. Архитектура и структура проекта @@ -85,7 +87,7 @@ flowchart TB | # | Действие | Приоритет | |---|----------|-----------| | A1 | Оставить текущую структуру; рефакторинг пакетов — только при старте 2.x | Низкий | -| A2 | Добавить в [architecture.md](architecture.md) диаграмму слоёв (как выше) | Низкий | +| A2 | Добавить в [architecture.md](architecture.md) диаграмму слоёв (как выше) — **выполнено** (§ Code layers) | Низкий | **Модель:** Sonnet (документация) @@ -130,14 +132,14 @@ flowchart TB | Файл | Замечание | Severity | |------|-----------|----------| | [`internal/web/token.go`](../internal/web/token.go) | `panic` при сбое `crypto/rand` — осознанно, документировано | Info | -| [`internal/web/handlers_domains.go`](../internal/web/handlers_domains.go) | Stale comment: «Applications and send log arrive in later phases» — уже реализовано | Low | -| Phase/spec references | ~50+ файлов с «Phase N», «spec 7.x» — шум для новых контрибьюторов | Low | -| [`cmd/panel/main.go`](../cmd/panel/main.go) | Package comment всё ещё упоминает «Phase 1 stubs» | Low | +| [`internal/web/handlers_domains.go`](../internal/web/handlers_domains.go) | Stale comment: «Applications and send log arrive in later phases» — **закрыто** (Фаза 1) | Low | +| Phase/spec references | ~50+ файлов с «Phase N», «spec 7.x» — **закрыто**: «Phase N» в Фазе 1, ссылки на архивную спецификацию — отдельным проходом (см. § 4) | Low | +| [`cmd/panel/main.go`](../cmd/panel/main.go) | Package comment всё ещё упоминает «Phase 1 stubs» — **закрыто** (Фаза 1) | Low | ### Потенциальные улучшения качества -- Единый проход **gofmt + удаление stale phase-комментариев** (механическая работа). -- Добавить **table-driven test** для edge cases в `parseDelivery` (exotic Postfix status values) — опционально. +- Единый проход **gofmt + удаление stale phase-комментариев** (механическая работа) — **выполнено** (Фаза 1; `gofmt -l` теперь и в CI). +- Добавить **table-driven test** для edge cases в `parseDelivery` (exotic Postfix status values) — **выполнено**, и проход оказался не косметическим: он вскрыл реальный баг. Шаблон разбора брал `status=` жадно, то есть **последнее** вхождение в строке, а Postfix дописывает в конец ответ удалённого сервера дословно. Отказ, в тексте ответа которого встречалось `status=sent`, попадал в журнал как доставленный. Исправлено на ленивый разбор (первое `status=` после получателя). **Модель:** Haiku (механическая чистка комментариев), Sonnet (точечные правки) @@ -157,18 +159,18 @@ flowchart TB | Проблема | Где | Реальность | |----------|-----|------------| -| Image tag `0.1.0` vs «v1.0» | [`deploy/docker-compose.yml`](../deploy/docker-compose.yml) vs README | Roadmap § v1.x — bump при теге | -| Quick start URLs | README | GitHub raw; основной repo — Codeberg | -| `docs/logo` | roadmap | Каталог отсутствует (не «пустой») | -| `docs/specification.md` | documentation-plan D9 | Архивирован в `docs/archive/`; ссылки в коде на «spec 7.x» устарели | -| Phase language в коде | 50+ файлов | Docs говорят «v1.0 done», код — «Phase 14» | -| RU/EN split | progress, roadmap, implementation-plan (RU) vs README/architecture (EN) | Намеренно, но барьер для EN-only contributors | +| Image tag `0.1.0` vs «v1.0» | [`deploy/docker-compose.yml`](../deploy/docker-compose.yml) vs README | **Открыто:** roadmap § v1.x — bump в релизном коммите вместе с git-тегом | +| Quick start URLs | README | **Закрыто:** Codeberg уходит как публичная площадка, единственный дом проекта — GitHub; вместе с URL переехал и путь Go-модуля (`github.com/mixeme/selfpost`) | +| `docs/logo` | roadmap | **Закрыто** (Фаза 1): каталог отсутствует, критерию удовлетворяет | +| `docs/specification.md` | documentation-plan D9 | **Закрыто:** файл остаётся в `docs/archive/` как история, но ссылок на него из кода больше нет — все «spec N.x» заменены на живые документы | +| Phase language в коде | 50+ файлов | **Закрыто** (Фаза 1) | +| RU/EN split | progress, roadmap, implementation-plan (RU) vs README/architecture (EN) | Намеренно, но барьер для EN-only contributors; снимается вместе с `CONTRIBUTING.md` — перенесено в [roadmap.md](roadmap.md) § 2.x | ### Комментирование кода - **Высокое качество** в security-critical paths. - **Среднее** в CRUD handlers (делегируют в services — acceptable). -- **Рекомендация:** заменить «spec 7.x» на ссылки на [product.md](product.md) / [security.md](security.md) § или удалить. +- **Выполнено:** ссылки на архивную спецификацию убраны из кода целиком — не только «spec 7.x», но и «spec 4/5/6/8/9», которые страдали ровно тем же (указывали в документ, помеченный «не источник истины»). Каждая заменена на живой документ, владеющий темой: [architecture.md](architecture.md) с указанием секции, [product.md](product.md), [security.md](security.md) или README. Секция указывается там, где документ большой (architecture.md, README); для короткого `product.md` — только файл. **Модель:** Sonnet (docs sync) @@ -339,20 +341,20 @@ Go `html/template` + HTMX polling + [`panel.css`](../internal/web/static/panel.c | # | Задача | Модель | Ref | |---|--------|--------|-----| -| R0 | Security review diff v1.0.0→HEAD + checklist 7.6 | **Fable** | implementation-plan § D | -| R1 | Bump image tag in compose при git tag | Sonnet | roadmap § v1.x | -| R2 | Codeberg URLs в README Quick start | Sonnet | roadmap § v1.x | +| R0 | Security review diff v1.0.0→HEAD + checklist 7.6 — **выполнено** (2026-08-06) | **Fable** | implementation-plan § D | +| R1 | Bump image tag in compose при git tag — **открыто**, делается в релизном коммите вместе с тегом | Sonnet | roadmap § v1.x | +| R2 | ~~Codeberg URLs в README Quick start~~ — **снято**: Codeberg уходит, GitHub остаётся единственной площадкой. Вместо перевода ссылок *на* Codeberg сделан обратный переезд: URL, лицензионные шапки SVG/HTML и путь Go-модуля | Sonnet | — | ### v1.x polish (не блокеры) | # | Задача | Модель | |---|--------|--------| -| R3 | Cleanup phase-комментариев (50+ files) | Haiku | -| R4 | Fix stale comment in handlers_domains.go | Haiku | -| R5 | docs/logo: создать или удалить из roadmap | Haiku | -| R6 | GUI: visibility-aware HTMX polling | Sonnet | -| R7 | CONTRIBUTING.md | Sonnet | -| R8 | ADR для CSRF policy | Sonnet | +| R3 | Cleanup phase-комментариев (50+ files) — **выполнено** (Фаза 1) | Haiku | +| R4 | Fix stale comment in handlers_domains.go — **выполнено** (Фаза 1) | Haiku | +| R5 | docs/logo: создать или удалить из roadmap — **выполнено** (Фаза 1) | Haiku | +| R6 | GUI: visibility-aware HTMX polling — **выполнено** (Фаза 2) | Sonnet | +| R7 | CONTRIBUTING.md — **перенесено в 2.x** ([roadmap.md](roadmap.md)): у проекта один разработчик и нет внешнего потока PR, документ был бы без аудитории | Sonnet | +| R8 | ADR для CSRF policy — **выполнено** (Фаза 1, [security.md](security.md)) | Sonnet | | R13 | Шифрование бэкапа и экспорта домена (checkbox + password) — **выполнено** | **Opus** + Sonnet | ### v2.x (roadmap, не начинать без согласования) @@ -368,7 +370,7 @@ Go `html/template` + HTMX polling + [`panel.css`](../internal/web/static/panel.c - E2E готов (`test/e2e/`); release workflow matrix amd64/arm64 — **хорошо**. - `go vet` + `go test` на push — **достаточно** для v1.x. -- Рекомендация: добавить `gofmt -l` check в CI (progress.md упоминает как manual step). +- Рекомендация: добавить `gofmt -l` check в CI (progress.md упоминает как manual step) — **выполнено** (Фаза 1, [.github/workflows/test.yml](../.github/workflows/test.yml)). **Модель:** Haiku (CI one-liner) @@ -378,12 +380,17 @@ Go `html/template` + HTMX polling + [`panel.css`](../internal/web/static/panel.c ### Фаза 0 — Гейт релиза (P0) -1. Fable: `/security-review` по diff v1.0.0...HEAD -2. Fable: ручной проход security.md checklist § 7.6 -3. Каждая finding → fix ИЛИ запись в security.md -4. `make e2e` зелёный (уже готов) -5. Sonnet: bump compose image tag + Codeberg URLs (в том же release commit) -6. Git tag vX.Y.Z +Содержательная часть закрыта 2026-08-06; остались только шаги самой резки +версии, которые делаются по явной команде оператора. + +1. Fable: `/security-review` по diff v1.0.0...HEAD — **выполнено** +2. Fable: ручной проход security.md checklist § 7.6 — **выполнено** +3. Каждая finding → fix ИЛИ запись в security.md — **выполнено** (одна правка + defence-in-depth, принятые риски не пополнились) +4. `make e2e` зелёный — **выполнено** (dev-сервер) +5. ~~Codeberg URLs~~ — **снято**, см. R2: переезд сделан в обратную сторону, на + GitHub. Остаётся bump тега образа в compose — **открыто**, в релизном коммите +6. Git tag vX.Y.Z — **открыто**, [roadmap.md](roadmap.md) § v1.x ### Фаза 1.5 — Шифрование резервных копий (P1, v1.x) — **выполнено 2026-08-06** @@ -409,20 +416,29 @@ Go `html/template` + HTMX polling + [`panel.css`](../internal/web/static/panel.c **Задачи:** E1 crypto envelope → E2 backup/CLI → E3 domain export/import → E4 UI (checkbox) → E5 docs + e2e. **Модель:** Opus (crypto), Sonnet (UI/docs). -### Фаза 1 — Doc/code hygiene (P1) +### Фаза 1 — Doc/code hygiene (P1) — **выполнено 2026-08-06** -1. Haiku: массовая замена phase-комментариев (mechanical pass) -2. Haiku: fix handlers_domains.go stale comment -3. Sonnet: ADR CSRF в security.md -4. Sonnet: known limitations § в architecture.md (send-log gap) -5. Haiku: docs/logo resolve -6. Haiku: gofmt CI check +1. Haiku: массовая замена phase-комментариев (mechanical pass) — **сделано** +2. Haiku: fix handlers_domains.go stale comment — **сделано** +3. Sonnet: ADR CSRF в security.md — **сделано** +4. Sonnet: known limitations § в architecture.md (send-log gap) — **уже было** + в § Log tailer, правка не потребовалась +5. Haiku: docs/logo resolve — **сделано** +6. Haiku: gofmt CI check — **сделано** -### Фаза 2 — GUI polish (P2, optional) +Добор той же фазы (2026-08-06, отдельным проходом): ссылки на архивную +спецификацию убраны из кода целиком (§ 4), добавлена диаграмма слоёв в +architecture.md (A2), расширен `TestParseDelivery` (§ 3) — последнее вскрыло +реальный баг разбора `status=`. -1. Sonnet: HTMX visibility-aware polling (panel.js) -2. Sonnet: CSS custom properties для dark mode -3. Haiku: consolidate main max-width rules +### Фаза 2 — GUI polish (P2, optional) — **выполнено 2026-08-06** + +1. Sonnet: HTMX visibility-aware polling (panel.js) — **сделано иначе**: + фильтр повешен на `htmx:beforeRequest`, а не на встроенный фильтр триггера + htmx — тот вычисляется через `new Function`, что CSP панели без + `unsafe-eval` молча ломает +2. Sonnet: CSS custom properties для dark mode — **сделано** +3. Haiku: consolidate main max-width rules — **сделано** ### Фаза 3 — Operational improvements (P2–P3, optional) — **выполнено 2026-08-06** diff --git a/docs/development.md b/docs/development.md index a6911dc..26d26e6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -35,7 +35,7 @@ Or directly: ```sh go vet ./... go test ./... -go build -trimpath -ldflags "-X codeberg.org/mix/selfpost/internal/buildinfo.Version=dev" -o bin/panel ./cmd/panel +go build -trimpath -ldflags "-X github.com/mixeme/selfpost/internal/buildinfo.Version=dev" -o bin/panel ./cmd/panel ``` **Env documentation regression:** `go test ./cmd/panel -run TestLoadConfig` — diff --git a/docs/documentation-plan.md b/docs/documentation-plan.md index a6df24a..37b848e 100644 --- a/docs/documentation-plan.md +++ b/docs/documentation-plan.md @@ -16,8 +16,9 @@ | Безопасность | [security.md](security.md) | | Исторический снимок v1.0 | [archive/specification-v1.0.md](archive/specification-v1.0.md) | -Отложенная полировка v1.x (Quick start на Codeberg, тег образа в compose, -`docs/logo`) — [roadmap.md](roadmap.md) § «v1.x — хвост документации и деплоя». +Отложенная полировка v1.x (тег образа в compose) — [roadmap.md](roadmap.md) +§ «v1.x — хвост документации и деплоя». Пункты про Quick start и `docs/logo` +закрыты. --- diff --git a/docs/progress.md b/docs/progress.md index 00c227f..0d6546d 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -47,13 +47,15 @@ - **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-сервере; локально на 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.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-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` — локальный/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` переработан: job `prepare` (версия из тега) → матрица `[ubuntu-latest, ubuntu-24.04-arm]` — каждая нативно собирает образ (`--load`), прогоняет e2e, пушит тег `X.Y.Z-amd64`/`X.Y.Z-arm64` → job `merge` — `docker buildx imagetools create` в единый тег `X.Y.Z`; `setup-qemu-action` убран. Не проверено вживую (нельзя без реального тега): сам workflow на GitHub Actions — синтаксис вычитан, логика идентична локальному `make e2e` пути. -- **Документация:** план D1–D9 закрыт ([documentation-plan.md](documentation-plan.md) — только метод и правила поддержки). Хвост v1.x (Codeberg в Quick start, тег образа, `docs/logo`) — [roadmap.md](roadmap.md) § «v1.x — хвост документации и деплоя». +- **Документация:** план D1–D9 закрыт ([documentation-plan.md](documentation-plan.md) — только метод и правила поддержки). Хвост v1.x — [roadmap.md](roadmap.md) § «v1.x — хвост документации и деплоя»; из него остался только бамп тега образа (Quick start и `docs/logo` закрыты). - **Рецензирование кодовой базы** (2026-08-05): [code-review.md](code-review.md) — 10 разделов (архитектура, качество, docs, GUI, legacy, риски), приоритизированный план реализации и маршрутизация моделей. Критичных багов не найдено; блокер релиза — § D ниже. - **§ D выполнен (2026-08-06):** предрелизная ревизия безопасности моделью Fable — диф от аудита v1.0 (Фаза 11, `bd64e80`) до HEAD + полный проход по чек-листу [security.md](security.md) (бывшее ТЗ 7.6). Эксплуатируемых находок нет; одна правка defence-in-depth (`--` перед логином в argv `saslpasswd2`, `internal/app/sasl.go` + тест). Принятые риски не пополнились. Детали — [implementation-plan.md](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](code-review.md) § Фаза 1 — doc/code hygiene, P1): cleanup ~30 stale «Phase N» комментариев в коде и shell-скриптах; исправлен stale-комментарий в `handlers_domains.go`; ADR CSRF (Origin vs токены) добавлен в [security.md](security.md); known-limitations по log-tailer уже был в [architecture.md](architecture.md) § Log tailer — отдельного действия не потребовалось; `docs/logo` в [roadmap.md](roadmap.md) закрыт (каталога нет, критерию соответствует); `gofmt -l` добавлен в CI (`.github/workflows/test.yml`). `gofmt`/`go vet`/`go test ./...` чистые в обоих модулях (dev-server). - **Фаза 1.5 выполнена (2026-08-06)** ([code-review.md](code-review.md) § Фаза 1.5 — шифрование резервных копий, 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](security.md) § «Резервная копия и экспорт домена» + принятый риск (шифрование опционально), [architecture.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](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](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-сервере. -- **Дальше:** релизный гейт (Фаза 0) закрыт по существу — e2e C.4 и ревизия § D пройдены; остаются только шаги, которые делаются в момент резки версии (бамп тега образа + Codeberg URL, git tag) по явной команде пользователя. Остальные пункты из [code-review.md](code-review.md) — polish (фазы 2–3). +- **Добор по 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](architecture.md) § Code layers — диаграмма слоёв (A2); (4) `TestParseDelivery` расширен экзотикой mail.log — и **вскрыл реальный баг**: шаблон брал `status=` жадно, то есть последнее вхождение в строке, а Postfix дописывает ответ удалённого сервера дословно, поэтому отказ с `status=sent` в тексте ответа попадал в журнал как доставленный (исправлено на ленивый разбор); (5) R7 (`CONTRIBUTING.md`) перенесён в 2.x, R1 и git-тег оставлены в [roadmap.md](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](code-review.md) (1, 1.5, 2, 3) закрыты. - **Принятые риски** — [security.md](security.md). **Опционально v1.x / 2.x** — [roadmap.md](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/` это делает). diff --git a/docs/roadmap.md b/docs/roadmap.md index 40193af..845cc09 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -21,15 +21,16 @@ [documentation-plan.md](documentation-plan.md) (бывшая находка 11 и отложенный пункт D4). Делать по желанию или в релизном коммите, где указано. -**Quick start на Codeberg.** [README.md](../README.md) Quick start тянет -`docker-compose.yml` и `.env.example` с `raw.githubusercontent.com` (зеркало). -Основной репозиторий — Codeberg; заменить URL на актуальные raw-ссылки -Codeberg (`codeberg.org/mix/selfpost/raw/branch/main/deploy/...`). - -**Тег образа в compose.** В [deploy/docker-compose.yml](../deploy/docker-compose.yml) -поле `image:` бампить до версии релиза **в том же коммите**, что и git-тег -`vX.Y.Z` — не раньше. Сейчас может отставать от целевой версии релиза; -несовпадение мешает только до первого выката по тегу. +**Тег образа в compose + git tag — один релизный коммит (R1).** В +[deploy/docker-compose.yml](../deploy/docker-compose.yml) поле `image:` бампить +до версии релиза **в том же коммите**, что и git-тег `vX.Y.Z` — не раньше. +Сейчас там `0.1.0`, то есть отстаёт от целевой версии; несовпадение мешает +только до первого выката по тегу. Сам тег — последний шаг Фазы 0 +[code-review.md](code-review.md): содержательная часть гейта (e2e C.4, ревизия +§ D) закрыта, режется по явной команде оператора. После тега `release.yml` +собирает и публикует `ghcr.io/mixeme/selfpost:X.Y.Z`, поэтому compose с новым +тегом и сам тег обязаны появиться вместе — иначе compose неделю ссылается на +несуществующий образ. **Убрать `implementation-plan.md` — в релизном коммите.** Документ закрыт: уникального содержания в нём нет, § D (предрелизная ревизия безопасности) @@ -53,12 +54,15 @@ CHANGELOG `[Unreleased]/Security`, а разделы B.1–B.3 и C.4 вырез 4. В [progress.md](progress.md) убрать шаг «Открыть `implementation-plan.md`» — он выполнен. -**Готово, когда:** Quick start указывает на Codeberg; тег образа в compose -совпадает с релизом; `implementation-plan.md` в `docs/archive/`, ссылок на него -в активных документах и в коде/CI не осталось. +**Готово, когда:** тег образа в compose совпадает с релизом и рядом стоит +git-тег `vX.Y.Z`; `implementation-plan.md` в `docs/archive/`, ссылок на него в +активных документах и в коде/CI не осталось. -(`docs/logo` уже отсутствует как каталог — критерию «либо содержит файлы, либо -отсутствует» удовлетворяет; отдельного действия не требуется.) +(Закрыто и действия не требует: `docs/logo` как каталога нет — критерию «либо +содержит файлы, либо отсутствует» удовлетворяет; Quick start в +[README.md](../README.md) тянет `docker-compose.yml` и `.env.example` с +`raw.githubusercontent.com` — это и есть единственная площадка проекта, зеркал +больше нет.) **Send-log vs `mail.log` (частично закрыто).** Persist позиции чтения сделан (Фаза 3 [code-review.md](code-review.md), таблица `logtail_state`): после @@ -123,3 +127,27 @@ CHANGELOG `[Unreleased]/Security`, а разделы B.1–B.3 и C.4 вырез ([development.md](development.md)), только потом код. Цена — уровня фазы, а не патча: таблица пользователей и их привязка к доменам, роль в сессии, авторизация в каждом хендлере (а не только на маршруте — сейчас `{id}`/`{aid}` не сверяются ни с чем, кроме существования), пересмотр первичного setup'а и смены пароля под нескольких пользователей, учёт нового субъекта в бэкапе и экспорте домена. *(Прежняя формулировка этого пункта — «2FA и несколько администраторов» — заменена: 2FA снята с рассмотрения, а «несколько администраторов» уточнено до одной конкретной роли, потому что нужна не вторая копия всевластного админа, а ограниченный доступ владельца отдельного домена.)* + +--- + +## `CONTRIBUTING.md` — кандидат на 2.x + +**Что это.** Точка входа для стороннего контрибьютора: dev loop (правка на +Windows → сборка и прогон на Debian-сервере, потому что Docker локально нет), +маршрутизация моделей по типу работы, протокол коммитов, требование +`gofmt`/`vet`/`test`/`make e2e` до PR. Сейчас всё это есть, но в +[development.md](development.md) и [progress.md](progress.md) — то есть на +русском и вперемешку с внутренним состоянием проекта. + +**Почему 2.x, а не v1.x** (перенесено из [code-review.md](code-review.md) § 10, +пункт R7, где стояло как «опционально v1.x»). Файл имеет смысл, когда есть +кому его читать: у проекта один разработчик и внешнего потока PR нет, поэтому +сейчас `CONTRIBUTING.md` был бы документом без аудитории и ещё одним местом, +где расходится правда о dev loop. Уместен вместе с тем, что реально открывает +проект вовне: английская документация процесса (сейчас RU/EN split — барьер +для EN-only контрибьюторов, [code-review.md](code-review.md) § 4) и первый +внешний интерес после публикации релиза. + +**Готово, когда:** `CONTRIBUTING.md` в корне описывает dev loop, требования к +проверкам перед PR и протокол коммитов; [development.md](development.md) не +дублирует его, а ссылается. diff --git a/go.mod b/go.mod index 5d4e1e8..0b64846 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module codeberg.org/mix/selfpost +module github.com/mixeme/selfpost go 1.26 diff --git a/internal/app/password.go b/internal/app/password.go index 8289a82..9e27703 100644 --- a/internal/app/password.go +++ b/internal/app/password.go @@ -8,12 +8,12 @@ import ( // passwordBytes is the number of random bytes behind a generated application // password. 24 bytes = 192 bits of entropy, well above any brute-force concern -// for a SASL credential the panel shows exactly once (spec 7.6.1). +// for a SASL credential the panel shows exactly once (security.md). const passwordBytes = 24 // generatePassword returns a strong, URL-safe random password for an // application's SASL account. The panel generates it, shows it once and never -// stores the plaintext (spec 7.6.1); sasldb2 keeps only the hashed form. +// stores the plaintext (security.md); sasldb2 keeps only the hashed form. // // base64url output keeps the password to a safe ASCII alphabet with no shell or // SMTP-special characters, so it survives being typed into client configuration diff --git a/internal/app/sasl.go b/internal/app/sasl.go index 4b6f0ad..84cc67a 100644 --- a/internal/app/sasl.go +++ b/internal/app/sasl.go @@ -10,11 +10,12 @@ import ( ) // SASLDB manages the Cyrus SASL account database (sasldb2) the panel maintains -// for application credentials (spec 5.1). The panel is the only writer; Postfix -// reads it to authenticate SMTP clients. Accounts are created and removed with -// the standard saslpasswd2 tool ("эквивалент saslpasswd2", per the plan). +// for application credentials (architecture.md § Mail path). The panel is the +// only writer; Postfix reads it to authenticate SMTP clients. Accounts are +// created and removed with the standard saslpasswd2 tool ("эквивалент +// saslpasswd2", per the plan). type SASLDB struct { - path string // sasldb2 file, under /data so it survives restarts (spec 9) + path string // sasldb2 file, under /data so it survives restarts realm string // SASL realm, so lookups match what Postfix's SASL uses // run executes saslpasswd2. It is a field so tests can substitute a fake; @@ -34,14 +35,14 @@ func NewSASLDB(path, realm string) *SASLDB { } // Set creates or updates an application's SASL account with the given password -// (spec 5.1, 7.2.9). Used both at creation and when a password is regenerated; -// saslpasswd2 overwrites an existing entry in place. +// (architecture.md § Mail path). Used both at creation and when a password is +// regenerated; saslpasswd2 overwrites an existing entry in place. // // The password is passed to saslpasswd2 on stdin (never as an argument, so it // cannot leak through the process table or logs). The login is passed as a // separate argv element after being whitelisted by validateLogin — it never -// goes through a shell and is never interpolated into a command string (spec -// 7.6.3). +// goes through a shell and is never interpolated into a command string +// (security.md). func (s *SASLDB) Set(login, password string) error { if err := validateLogin(login); err != nil { return err @@ -60,7 +61,7 @@ func (s *SASLDB) Set(login, password string) error { return nil } -// Delete removes an application's SASL account (spec 7.2.8). A missing account +// Delete removes an application's SASL account (product.md). A missing account // is not treated as an error, so deletion is idempotent and safe to retry. func (s *SASLDB) Delete(login string) error { if err := validateLogin(login); err != nil { @@ -79,15 +80,16 @@ func (s *SASLDB) Delete(login string) error { var ErrSecretNotFound = fmt.Errorf("sasl secret not found") // Secret returns an application's stored password so it can be carried in a -// domain export and re-created verbatim on another instance (spec 7.5.B). This -// is possible because sasldb2 keeps the SASL secret in a password-equivalent -// form (the plaintext userPassword property, to serve challenge-response -// mechanisms) — unlike the admin's one-way bcrypt hash (spec 7.6). The value is -// realm-independent, so the importer can re-key it under its own realm. +// domain export and re-created verbatim on another instance (architecture.md § +// Persistence). This is possible because sasldb2 keeps the SASL secret in a +// password-equivalent form (the plaintext userPassword property, to serve +// challenge-response mechanisms) — unlike the admin's one-way bcrypt hash +// (security.md). The value is realm-independent, so the importer can re-key it +// under its own realm. // // It reads the database with db_dump (Berkeley DB), passing only our own file -// path as a fixed argument (no shell, no user input — spec 7.6.3), and returns -// ErrSecretNotFound if the login has no entry. +// path as a fixed argument (no shell, no user input — security.md), and +// returns ErrSecretNotFound if the login has no entry. func (s *SASLDB) Secret(login string) (string, error) { if err := validateLogin(login); err != nil { return "", err @@ -158,7 +160,7 @@ func parseSASLSecret(dump []byte, login, realm string) (string, bool, error) { // runSaslpasswd2 executes the real saslpasswd2 with the given arguments and // stdin. Arguments are passed as a fixed argv (no shell), so no user input is -// ever interpreted as a command (spec 7.6.3). +// ever interpreted as a command (security.md). func runSaslpasswd2(args []string, stdin []byte) error { cmd := exec.Command("saslpasswd2", args...) if stdin != nil { @@ -173,7 +175,7 @@ func runSaslpasswd2(args []string, stdin []byte) error { // dumpSASLDB runs db_dump to export the sasldb2 as key/value hex pairs. The path // is our own sasldb2 file (never user input) and is passed as a fixed argument -// with no shell (spec 7.6.3). +// with no shell (security.md). func dumpSASLDB(path string) ([]byte, error) { cmd := exec.Command("db_dump", path) out, err := cmd.CombinedOutput() diff --git a/internal/app/service.go b/internal/app/service.go index b088eb7..43beb3e 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -1,13 +1,13 @@ -// Package app owns application accounts (spec 4.1, 5.1): the SASL credentials in -// sasldb2, the per-application sender address mode, and the -// smtpd_sender_login_maps bindings that tie each login to the addresses it may -// send from. It keeps those three stores — the SQLite registry, sasldb2 and the -// Postfix map — in agreement and drives the Postfix reload. +// Package app owns application accounts (product.md § Multi-domain model): the +// SASL credentials in sasldb2, the per-application sender address mode, and +// the smtpd_sender_login_maps bindings that tie each login to the addresses it +// may send from. It keeps those three stores — the SQLite registry, sasldb2 +// and the Postfix map — in agreement and drives the Postfix reload. package app import ( - "codeberg.org/mix/selfpost/internal/postfix" - "codeberg.org/mix/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/postfix" + "github.com/mixeme/selfpost/internal/store" ) // SenderMaps is the slice of the Postfix manager the application service needs: @@ -19,7 +19,7 @@ type SenderMaps interface { // Service coordinates application state across SQLite, sasldb2 and the Postfix // sender_login_maps. Web handlers validate raw input first; the Service performs -// the domain-ownership checks that must not be skipped (spec 7.6.2) and keeps +// the domain-ownership checks that must not be skipped (security.md) and keeps // the stores consistent. type Service struct { store *store.Store @@ -33,7 +33,7 @@ func NewService(st *store.Store, sasl *SASLDB, pf SenderMaps) *Service { return &Service{store: st, sasl: sasl, pf: pf} } -// List returns a domain's applications with their address lists (spec 7.2.6). +// List returns a domain's applications with their address lists (product.md). func (s *Service) List(domainID int64) ([]store.Application, error) { return s.store.ListApplicationsByDomain(domainID) } @@ -44,10 +44,10 @@ func (s *Service) Get(id int64) (store.Application, error) { } // Create adds an application to a domain: it validates the login and (in list -// mode) that every address belongs to the domain (spec 7.6.2), generates a +// mode) that every address belongs to the domain (security.md), generates a // strong password, writes the SASL account and rebuilds the sender map (spec // 7.2.5). The generated password is returned so the caller can show it exactly -// once (spec 7.6.1) — it is never persisted in plaintext. +// once (security.md) — it is never persisted in plaintext. // // The registry row is written first so its UNIQUE constraint is the sole arbiter // of a duplicate login (avoiding a check-then-act race and, crucially, avoiding @@ -92,13 +92,14 @@ func (s *Service) rollbackCreate(id int64, login string) { _, _ = s.store.DeleteApplication(id) } -// ImportApplication re-creates an application from a domain-export file (spec -// 7.5.B): it validates the login and (in list mode) that every address belongs -// to the domain, inserts the registry row and writes the SASL account with the -// imported password verbatim, re-keyed under this instance's realm so the -// credential keeps working without regeneration. It deliberately does not -// rebuild the sender map — the caller (domain import) does that once after all -// applications are in — and returns store.ErrLoginExists if the login collides. +// ImportApplication re-creates an application from a domain-export file +// (architecture.md § Persistence): it validates the login and (in list mode) +// that every address belongs to the domain, inserts the registry row and +// writes the SASL account with the imported password verbatim, re-keyed under +// this instance's realm so the credential keeps working without regeneration. +// It deliberately does not rebuild the sender map — the caller (domain import) +// does that once after all applications are in — and returns +// store.ErrLoginExists if the login collides. func (s *Service) ImportApplication(domainID int64, login, mode string, rawAddresses []string, password string) error { addresses, err := s.validateForDomain(domainID, login, mode, rawAddresses) if err != nil { @@ -118,14 +119,15 @@ func (s *Service) ImportApplication(domainID int64, login, mode string, rawAddre return nil } -// Secret returns an application's stored password for a domain export (spec -// 7.5.B). See SASLDB.Secret for why this is possible and safe. +// Secret returns an application's stored password for a domain export +// (architecture.md § Persistence). See SASLDB.Secret for why this is possible +// and safe. func (s *Service) Secret(login string) (string, error) { return s.sasl.Secret(login) } // UpdateMode switches an application's address mode / list and rebuilds the -// sender map (spec 7.2.7). The login and password are untouched. Addresses are +// sender map (product.md). The login and password are untouched. Addresses are // re-validated against the application's domain. func (s *Service) UpdateMode(id int64, mode string, rawAddresses []string) error { a, err := s.store.GetApplication(id) @@ -172,22 +174,23 @@ func (s *Service) Delete(id int64) error { if err := s.sasl.Delete(a.Login); err != nil { return err } - // Drop the application's level-2 limit, if any (spec 7.4); rate_limits has no - // cascade of its own. + // Drop the application's level-2 limit, if any (README § Rate limiting); + // rate_limits has no cascade of its own. if err := s.store.DeleteRateLimit(store.RateLimitScopeApp, id); err != nil { return err } return s.Resync() } -// RateLimit returns the application-level differentiated rate limit (spec 7.4), -// and whether one is configured, for the application's edit form. +// RateLimit returns the application-level differentiated rate limit (README § +// Rate limiting), and whether one is configured, for the application's edit +// form. func (s *Service) RateLimit(appID int64) (store.RateLimit, bool, error) { return s.store.GetRateLimit(store.RateLimitScopeApp, appID) } // SaveRateLimit stores the application-level rate limit. The caller has validated -// the IPs and numbers (spec 7.6.2); the milter reads the row live, so no reload +// the IPs and numbers (security.md); the milter reads the row live, so no reload // is needed. func (s *Service) SaveRateLimit(appID int64, ips []string, maxMessages, windowSeconds int) error { return s.store.SetRateLimit(store.RateLimit{ @@ -199,14 +202,15 @@ func (s *Service) SaveRateLimit(appID int64, ips []string, maxMessages, windowSe }) } -// ClearRateLimit removes the application-level rate limit (spec 7.4). +// ClearRateLimit removes the application-level rate limit (README § Rate +// limiting). func (s *Service) ClearRateLimit(appID int64) error { return s.store.DeleteRateLimit(store.RateLimitScopeApp, appID) } // PurgeDomainSASL removes the SASL accounts of every application bound to a // domain. It must be called before the domain's registry rows are cascade- -// deleted, while the logins are still known (spec 7.2.4). The registry rows and +// deleted, while the logins are still known (product.md). The registry rows and // the sender map are handled by the domain deletion path; this only clears // sasldb2, which has no cascade of its own. func (s *Service) PurgeDomainSASL(domainID int64) error { @@ -223,10 +227,10 @@ func (s *Service) PurgeDomainSASL(domainID int64) error { } // Resync rebuilds smtpd_sender_login_maps from the full set of application -// bindings and reloads Postfix (spec 5.1). It is the single idempotent apply -// path shared by create/edit/delete and is also reachable from the manual -// reload button; it doubles as recovery if the map ever drifts from the -// database. +// bindings and reloads Postfix (architecture.md § Mail path). It is the single +// idempotent apply path shared by create/edit/delete and is also reachable +// from the manual reload button; it doubles as recovery if the map ever drifts +// from the database. func (s *Service) Resync() error { bindings, err := s.store.ListBindings() if err != nil { @@ -241,7 +245,7 @@ func (s *Service) Resync() error { // validateForDomain resolves the domain, validates the login and address mode, // and — in list mode — validates that every address belongs to the domain -// (spec 7.6.2). It returns the cleaned address list, which is empty in wildcard +// (security.md). It returns the cleaned address list, which is empty in wildcard // mode. Resolving the domain here also confirms it exists before any write. func (s *Service) validateForDomain(domainID int64, login, mode string, rawAddresses []string) ([]string, error) { d, err := s.store.GetDomain(domainID) diff --git a/internal/app/service_test.go b/internal/app/service_test.go index da9825d..bc99516 100644 --- a/internal/app/service_test.go +++ b/internal/app/service_test.go @@ -5,8 +5,8 @@ import ( "path/filepath" "testing" - "codeberg.org/mix/selfpost/internal/postfix" - "codeberg.org/mix/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/postfix" + "github.com/mixeme/selfpost/internal/store" ) // fakeMaps records the last set of bindings passed to a rebuild and can be told diff --git a/internal/app/validate.go b/internal/app/validate.go index 68f7d7b..31a42c2 100644 --- a/internal/app/validate.go +++ b/internal/app/validate.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "codeberg.org/mix/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/store" ) const ( @@ -13,12 +13,12 @@ const ( ) // validateLogin enforces a strict server-side whitelist for the SASL login -// (spec 7.6.2). It intentionally excludes '@': the login is stored in sasldb2, +// (security.md). It intentionally excludes '@': the login is stored in sasldb2, // where '@' separates the user from the realm, so allowing it would change the // account's identity. Client validation is never trusted. // // The login is the one piece of user input that is passed to saslpasswd2 as a -// command argument (never through a shell, spec 7.6.3); this whitelist is what +// command argument (never through a shell, security.md); this whitelist is what // makes that safe. func validateLogin(login string) error { if len(login) < minLoginLen || len(login) > maxLoginLen { @@ -36,10 +36,11 @@ func validateLogin(login string) error { } // validateImportedPassword guards a password taken from a domain-export file -// (spec 7.5.B) before it is written to sasldb2. Our own exports carry base64url -// passwords, but the file is untrusted input, so we reject an empty value or one -// containing control characters — saslpasswd2 reads the passphrase from stdin -// and a newline would silently truncate it (spec 7.6.2). +// (architecture.md § Persistence) before it is written to sasldb2. Our own +// exports carry base64url passwords, but the file is untrusted input, so we +// reject an empty value or one containing control characters — saslpasswd2 +// reads the passphrase from stdin and a newline would silently truncate it +// (security.md). func validateImportedPassword(password string) error { if password == "" { return fmt.Errorf("imported application password is empty") @@ -71,7 +72,7 @@ func normalizeAddress(addr string) string { } // validateSenderAddress enforces that a list-mode address is well-formed and, -// critically, belongs to the application's own domain (spec 7.6.2). The domain +// critically, belongs to the application's own domain (security.md). The domain // check is done here, before anything is written to a config file — not left to // smtpd_sender_login_maps to catch at delivery time. domain must already be a // validated, normalised domain name. @@ -92,7 +93,7 @@ func validateSenderAddress(addr, domain string) error { // validateLocalPart applies a conservative whitelist to the part before '@'. // This is deliberately stricter than RFC 5321 (no quoted local parts) so the -// value is always safe to write verbatim into the Postfix map (spec 7.6.4). +// value is always safe to write verbatim into the Postfix map (security.md). func validateLocalPart(local string) error { if local == "" { return fmt.Errorf("missing the part before '@'") diff --git a/internal/app/validate_test.go b/internal/app/validate_test.go index 288a62b..f50d148 100644 --- a/internal/app/validate_test.go +++ b/internal/app/validate_test.go @@ -25,7 +25,7 @@ func TestValidateLogin(t *testing.T) { } func TestValidateSenderAddressDomainOwnership(t *testing.T) { - // The critical check (spec 7.6.2): an address must belong to the app's domain. + // The critical check (security.md): an address must belong to the app's domain. if err := validateSenderAddress("alerts@example.com", "example.com"); err != nil { t.Errorf("same-domain address rejected: %v", err) } diff --git a/internal/backup/backup.go b/internal/backup/backup.go index 439835d..d1bf4f8 100644 --- a/internal/backup/backup.go +++ b/internal/backup/backup.go @@ -1,17 +1,18 @@ // Package backup implements SelfPost's full-server backup and the restore -// version guard (spec 7.5.A). A full backup is a gzip-compressed tar of the -// consolidated persistent state under /data — the SQLite database (as a -// consistent snapshot), the per-domain DKIM keys and the SASL database — plus a -// manifest recording the SelfPost version that produced it. TLS certificates -// (the reverse proxy's responsibility) and the Postfix queue are deliberately -// excluded (spec 7.5.A). +// version guard (architecture.md § Persistence). A full backup is a +// gzip-compressed tar of the consolidated persistent state under /data — the +// SQLite database (as a consistent snapshot), the per-domain DKIM keys and the +// SASL database — plus a manifest recording the SelfPost version that produced +// it. TLS certificates (the reverse proxy's responsibility) and the Postfix +// queue are deliberately excluded (architecture.md § Persistence). // -// Restore is intentionally not a separate code path: a backup is extracted into -// the /data bind mount before first start, and the panel regenerates Postfix and -// OpenDKIM from the restored SQLite state exactly as on any normal start. The -// only restore-specific step is CheckRestore, which refuses to boot if the -// manifest's version does not match the running binary, so schema/format skew -// between versions cannot silently corrupt state (spec 7.5.A). +// Restore is intentionally not a separate code path: a backup is extracted +// into the /data bind mount before first start, and the panel regenerates +// Postfix and OpenDKIM from the restored SQLite state exactly as on any normal +// start. The only restore-specific step is CheckRestore, which refuses to boot +// if the manifest's version does not match the running binary, so +// schema/format skew between versions cannot silently corrupt state +// (architecture.md § Persistence). package backup import ( @@ -39,9 +40,9 @@ const FormatFull = "selfpost-full-backup" const ManifestName = "manifest.json" // Manifest is the small JSON document embedded in every backup archive. Its -// Version is the single fact that makes restore safe: the panel refuses to boot -// a data directory whose manifest version does not match its own binary (spec -// 7.5.A). +// Version is the single fact that makes restore safe: the panel refuses to +// boot a data directory whose manifest version does not match its own binary +// (architecture.md § Persistence). type Manifest struct { Format string `json:"format"` Version string `json:"version"` @@ -63,9 +64,9 @@ type Params struct { // written under the canonical name; the setup token is transient bootstrap // state; a stale manifest from a previous restore must not be re-captured (a // fresh one is written instead); and a "tls" directory holds the reverse -// proxy's certificates, which are explicitly out of scope for a SelfPost backup -// (spec 7.5.A) — excluding it keeps that guarantee even when an operator points -// TLS_CERT_FILE inside /data. +// proxy's certificates, which are explicitly out of scope for a SelfPost +// backup (architecture.md § Persistence) — excluding it keeps that guarantee +// even when an operator points TLS_CERT_FILE inside /data. var excludedFromArchive = map[string]bool{ "selfpost.db": true, "selfpost.db-wal": true, @@ -78,9 +79,10 @@ var excludedFromArchive = map[string]bool{ // Create writes a gzip-compressed tar backup to w. Archive entries are named // relative to DataDir, so extracting the archive into the /data bind mount -// reconstructs the state in place (spec 7.5.A). The SQLite database is added as -// a consistent snapshot under "selfpost.db"; everything else under DataDir is -// copied as-is except the entries in excludedFromArchive. +// reconstructs the state in place (architecture.md § Persistence). The SQLite +// database is added as a consistent snapshot under "selfpost.db"; everything +// else under DataDir is copied as-is except the entries in +// excludedFromArchive. func Create(w io.Writer, p Params) error { if p.DataDir == "" || p.DBPath == "" { return fmt.Errorf("backup: DataDir and DBPath are required") @@ -254,12 +256,13 @@ func snapshotDB(dbPath string) (path string, cleanup func(), err error) { return target, cleanup, nil } -// CheckRestore enforces the backup version guard (spec 7.5.A). If manifestPath -// exists (a backup was extracted into the data directory), its version must -// match binaryVersion or the panel refuses to start, telling the operator which -// image tag to use. On a match the manifest is consumed (deleted) so it guards -// only the first boot after a restore and never blocks a later in-place image -// upgrade. Absence of the manifest is the normal case and returns nil. +// CheckRestore enforces the backup version guard (architecture.md § +// Persistence). If manifestPath exists (a backup was extracted into the data +// directory), its version must match binaryVersion or the panel refuses to +// start, telling the operator which image tag to use. On a match the manifest +// is consumed (deleted) so it guards only the first boot after a restore and +// never blocks a later in-place image upgrade. Absence of the manifest is the +// normal case and returns nil. func CheckRestore(manifestPath, binaryVersion string) error { data, err := os.ReadFile(manifestPath) if os.IsNotExist(err) { diff --git a/internal/backup/backup_test.go b/internal/backup/backup_test.go index 0ba5da5..7545f72 100644 --- a/internal/backup/backup_test.go +++ b/internal/backup/backup_test.go @@ -11,7 +11,7 @@ import ( "strings" "testing" - "codeberg.org/mix/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/store" ) // seedDataDir builds a realistic /data tree: a migrated SQLite database plus the diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go index 44682e2..e72117f 100644 --- a/internal/buildinfo/buildinfo.go +++ b/internal/buildinfo/buildinfo.go @@ -3,8 +3,8 @@ package buildinfo // Version is the SelfPost release. It is set at build time with // -// -ldflags "-X codeberg.org/mix/selfpost/internal/buildinfo.Version=" +// -ldflags "-X github.com/mixeme/selfpost/internal/buildinfo.Version=" // // and must match the Docker image tag; it is used for the backup/restore -// compatibility check (spec 7.5.A). Defaults to "dev" for local/unstamped builds. +// compatibility check (architecture.md § Persistence). Defaults to "dev" for local/unstamped builds. var Version = "dev" diff --git a/internal/dnscheck/dnscheck.go b/internal/dnscheck/dnscheck.go index 87e945c..ba33e4f 100644 --- a/internal/dnscheck/dnscheck.go +++ b/internal/dnscheck/dnscheck.go @@ -18,7 +18,7 @@ import ( "sync" "time" - "codeberg.org/mix/selfpost/internal/health" + "github.com/mixeme/selfpost/internal/health" ) const ( diff --git a/internal/dnscheck/dnscheck_test.go b/internal/dnscheck/dnscheck_test.go index 04eddb9..11eb75c 100644 --- a/internal/dnscheck/dnscheck_test.go +++ b/internal/dnscheck/dnscheck_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "codeberg.org/mix/selfpost/internal/health" + "github.com/mixeme/selfpost/internal/health" ) // fakeResolver serves a fixed zone, so the checks can be driven through every diff --git a/internal/dnscheck/examples_test.go b/internal/dnscheck/examples_test.go index 6cc03d4..2a542cf 100644 --- a/internal/dnscheck/examples_test.go +++ b/internal/dnscheck/examples_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "codeberg.org/mix/selfpost/internal/health" + "github.com/mixeme/selfpost/internal/health" ) func TestSPFExample(t *testing.T) { diff --git a/internal/dnscheck/records.go b/internal/dnscheck/records.go index b9f666b..6d1c9ae 100644 --- a/internal/dnscheck/records.go +++ b/internal/dnscheck/records.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - "codeberg.org/mix/selfpost/internal/health" + "github.com/mixeme/selfpost/internal/health" ) // checkDKIM compares the TXT record published at ._domainkey. diff --git a/internal/dnscheck/server.go b/internal/dnscheck/server.go index b711d58..728f1ab 100644 --- a/internal/dnscheck/server.go +++ b/internal/dnscheck/server.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "codeberg.org/mix/selfpost/internal/health" + "github.com/mixeme/selfpost/internal/health" ) // checkServer resolves the panel's own hostname and confirms the reverse diff --git a/internal/dnscheck/spf.go b/internal/dnscheck/spf.go index 71412fd..e8ca01a 100644 --- a/internal/dnscheck/spf.go +++ b/internal/dnscheck/spf.go @@ -6,7 +6,7 @@ import ( "net" "strings" - "codeberg.org/mix/selfpost/internal/health" + "github.com/mixeme/selfpost/internal/health" ) // spfLookupBudget caps how many DNS lookups one SPF evaluation may spend on diff --git a/internal/domain/dkim.go b/internal/domain/dkim.go index 40fe459..aa086aa 100644 --- a/internal/domain/dkim.go +++ b/internal/domain/dkim.go @@ -1,8 +1,8 @@ // Package domain owns SelfPost's sending-domain model: per-domain DKIM key // generation, the OpenDKIM KeyTable/SigningTable that drive signing, and the // orchestration that keeps the SQLite registry, the on-disk keys and OpenDKIM -// in agreement (spec 4.1, 6). Key material lives under /data so it survives -// container restarts (spec 6.1, 9). +// in agreement (architecture.md § OpenDKIM). Key material lives under /data so +// it survives container restarts (architecture.md § OpenDKIM). package domain import ( @@ -46,7 +46,7 @@ func writePrivateKeyPEM(path string, key *rsa.PrivateKey) error { // loadPrivateKeyPEM reads and parses a PKCS#1 RSA private key written by // writePrivateKeyPEM. It is used to recompute the public DNS record on demand, -// keeping the private key file the single source of truth (spec 7.2.10). +// keeping the private key file the single source of truth (product.md). func loadPrivateKeyPEM(path string) (*rsa.PrivateKey, error) { data, err := os.ReadFile(path) if err != nil { @@ -63,7 +63,7 @@ func loadPrivateKeyPEM(path string) (*rsa.PrivateKey, error) { return key, nil } -// DKIMRecord is the DNS TXT record a user must publish for a domain (spec 7.2.10). +// DKIMRecord is the DNS TXT record a user must publish for a domain (product.md). type DKIMRecord struct { // Name is the record's host, e.g. "selfpost._domainkey.example.com". Name string @@ -89,7 +89,7 @@ func dkimRecord(selector, domainName string, pub *rsa.PublicKey) (DKIMRecord, er // writeFileAtomic writes data to path via a temp file in the same directory // followed by a rename, so readers only ever see the complete old or new file. // It is the single safe-write primitive for DKIM keys and OpenDKIM tables -// (spec 7.6.4). +// (security.md). func writeFileAtomic(path string, data []byte, perm os.FileMode) error { dir := filepath.Dir(path) tmp, err := os.CreateTemp(dir, ".tmp-*") diff --git a/internal/domain/opendkim.go b/internal/domain/opendkim.go index 92b3f92..434e30b 100644 --- a/internal/domain/opendkim.go +++ b/internal/domain/opendkim.go @@ -12,9 +12,9 @@ import ( ) // OpenDKIM manages the on-disk OpenDKIM state the panel is responsible for -// (spec 6): per-domain signing keys under keysDir and the KeyTable/SigningTable -// that map domains to those keys. After rewriting the tables it asks OpenDKIM to -// reload them. +// (architecture.md § OpenDKIM): per-domain signing keys under keysDir and the +// KeyTable/SigningTable that map domains to those keys. After rewriting the +// tables it asks OpenDKIM to reload them. type OpenDKIM struct { keysDir string keyTablePath string @@ -50,8 +50,8 @@ func (o *OpenDKIM) keyPath(domainName, selector string) string { // EnsureKey makes sure a signing key exists for the domain. An existing key is // reused untouched — critical because overwriting it would silently invalidate -// the DKIM record already published in DNS (spec 6.1). Returns whether a new key -// was generated. +// the DKIM record already published in DNS (architecture.md § OpenDKIM). +// Returns whether a new key was generated. func (o *OpenDKIM) EnsureKey(domainName, selector string) (bool, error) { if err := assertConfigSafe(domainName, selector); err != nil { return false, err @@ -77,8 +77,8 @@ func (o *OpenDKIM) EnsureKey(domainName, selector string) (bool, error) { return true, nil } -// RemoveKey deletes a domain's key directory (spec 6.5). A missing directory is -// not an error. +// RemoveKey deletes a domain's key directory (architecture.md § OpenDKIM). A +// missing directory is not an error. func (o *OpenDKIM) RemoveKey(domainName string) error { if err := assertConfigSafe(domainName, "x"); err != nil { return err @@ -89,10 +89,11 @@ func (o *OpenDKIM) RemoveKey(domainName string) error { return nil } -// ExportKey returns a domain's DKIM private key as PKCS#1 PEM, for carrying in a -// domain export so the receiving instance signs with the same key and the DNS -// TXT record never has to change (spec 7.5.B). It re-marshals the parsed key -// rather than returning the raw file, so a malformed on-disk key is caught here. +// ExportKey returns a domain's DKIM private key as PKCS#1 PEM, for carrying in +// a domain export so the receiving instance signs with the same key and the +// DNS TXT record never has to change (architecture.md § Persistence). It +// re-marshals the parsed key rather than returning the raw file, so a +// malformed on-disk key is caught here. func (o *OpenDKIM) ExportKey(domainName, selector string) ([]byte, error) { if err := assertConfigSafe(domainName, selector); err != nil { return nil, err @@ -105,11 +106,12 @@ func (o *OpenDKIM) ExportKey(domainName, selector string) ([]byte, error) { return pem.EncodeToMemory(block), nil } -// ImportKey writes an imported DKIM private key to disk for a domain (spec -// 7.5.B). The PEM is parsed and re-marshalled through the same writer used for -// generated keys, so only a well-formed PKCS#1 RSA key is ever stored. Unlike -// EnsureKey it overwrites: an import (re-)creates the domain with exactly this -// key, which is the whole point of keeping the published DNS record valid. +// ImportKey writes an imported DKIM private key to disk for a domain +// (architecture.md § Persistence). The PEM is parsed and re-marshalled through +// the same writer used for generated keys, so only a well-formed PKCS#1 RSA +// key is ever stored. Unlike EnsureKey it overwrites: an import (re-)creates +// the domain with exactly this key, which is the whole point of keeping the +// published DNS record valid. func (o *OpenDKIM) ImportKey(domainName, selector string, pemKey []byte) error { if err := assertConfigSafe(domainName, selector); err != nil { return err @@ -130,7 +132,7 @@ func (o *OpenDKIM) ImportKey(domainName, selector string, pemKey []byte) error { } // Record returns the published DKIM DNS record for a domain, recomputed from the -// private key on disk (spec 7.2.10). +// private key on disk (product.md). func (o *OpenDKIM) Record(domainName, selector string) (DKIMRecord, error) { key, err := loadPrivateKeyPEM(o.keyPath(domainName, selector)) if err != nil { @@ -140,10 +142,10 @@ func (o *OpenDKIM) Record(domainName, selector string) (DKIMRecord, error) { } // Rebuild regenerates KeyTable and SigningTable from the full domain set and -// reloads OpenDKIM (spec 6.2). Full regeneration (rather than incremental -// edits) keeps the files a pure function of the registry, so add and delete -// share one idempotent path. Both files are written atomically before the -// reload signal is sent. +// reloads OpenDKIM (architecture.md § OpenDKIM). Full regeneration (rather +// than incremental edits) keeps the files a pure function of the registry, so +// add and delete share one idempotent path. Both files are written atomically +// before the reload signal is sent. func (o *OpenDKIM) Rebuild(domains []SigningDomain) error { keyTable, signingTable, err := renderTables(o.keysDir, domains) if err != nil { @@ -158,17 +160,18 @@ func (o *OpenDKIM) Rebuild(domains []SigningDomain) error { return o.reload() } -// Reload asks OpenDKIM to re-read its tables without regenerating them. It backs -// the panel's manual reload button (spec 7.2.12). +// Reload asks OpenDKIM to re-read its tables without regenerating them. It +// backs the panel's manual reload button (architecture.md § Panel HTTP +// surface). func (o *OpenDKIM) Reload() error { return o.reload() } // renderTables builds the KeyTable and SigningTable byte contents for a domain // set, sorted by name so the output is deterministic. Every domain is -// re-checked for shell/config-injection safety before being written (spec -// 7.6.4) — validation upstream already guarantees this, but the table writer -// refuses to emit anything unsafe as a hard backstop. +// re-checked for shell/config-injection safety before being written +// (security.md) — validation upstream already guarantees this, but the table +// writer refuses to emit anything unsafe as a hard backstop. func renderTables(keysDir string, domains []SigningDomain) (keyTable, signingTable []byte, err error) { sorted := append([]SigningDomain(nil), domains...) sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name }) @@ -191,9 +194,9 @@ func renderTables(keysDir string, domains []SigningDomain) (keyTable, signingTab // assertConfigSafe rejects any domain/selector value that could break out of a // single table line. Domains are already whitelisted to [a-z0-9.-] and selectors -// to a similar set before they reach here (spec 7.6.2); this is defence in depth +// to a similar set before they reach here (security.md); this is defence in depth // against a validation gap ever letting whitespace, a newline or a field -// separator through into a config file (spec 7.6.4). +// separator through into a config file (security.md). func assertConfigSafe(domainName, selector string) error { for _, v := range []string{domainName, selector} { if v == "" { @@ -210,10 +213,10 @@ func assertConfigSafe(domainName, selector string) error { // OpenDKIM process SIGUSR1, which makes it re-read KeyTable/SigningTable // (opendkim's documented reload signal). The panel runs unprivileged and cannot // signal another user's process directly, so it goes through the supervisor -// control socket, reachable via the shared `selfpost` group (spec 7.6.3, 7.6.8). +// control socket, reachable via the shared `selfpost` group (security.md). // // Arguments are fixed literals — no user input is interpolated into the command, -// and it never goes through a shell (spec 7.6.3). +// and it never goes through a shell (security.md). func reloadViaSupervisor() error { cmd := exec.Command("supervisorctl", "-c", "/etc/supervisor/supervisord.conf", diff --git a/internal/domain/service.go b/internal/domain/service.go index 4a4ff51..44f77f9 100644 --- a/internal/domain/service.go +++ b/internal/domain/service.go @@ -3,7 +3,7 @@ package domain import ( "fmt" - "codeberg.org/mix/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/store" ) // Applications is the slice of the application service the domain service needs @@ -19,18 +19,20 @@ type Applications interface { // and reloads Postfix. Resync() error // Secret returns an application's stored password, for a domain export - // (spec 7.5.B). + // (architecture.md § Persistence). Secret(login string) (string, error) // ImportApplication re-creates an application (registry row + SASL account) - // from a domain-export file, without rebuilding the sender map (spec 7.5.B). + // from a domain-export file, without rebuilding the sender map + // (architecture.md § Persistence). ImportApplication(domainID int64, login, mode string, addresses []string, password string) error } // Service coordinates the places a sending domain lives: the SQLite registry, // the on-disk DKIM keys and OpenDKIM's tables, plus — on deletion — the SASL // database and Postfix sender map its applications touch. Callers (the web -// handlers) validate user input first; Service keeps the stores in agreement and -// drives the OpenDKIM/Postfix reloads (spec 6, 7.2.2-4, 7.2.10). +// handlers) validate user input first; Service keeps the stores in agreement +// and drives the OpenDKIM/Postfix reloads (architecture.md § OpenDKIM, +// product.md). type Service struct { store *store.Store odk *OpenDKIM @@ -39,14 +41,15 @@ type Service struct { } // NewService builds the domain service. selectorDefault is the DKIM selector -// assigned to new domains (spec 8: DKIM_SELECTOR_DEFAULT); it is -// operator-configured, not user input. apps is used only on deletion, to clear -// the SASL accounts and sender-map bindings of the domain's applications. +// assigned to new domains (README § Environment variables: +// DKIM_SELECTOR_DEFAULT); it is operator-configured, not user input. apps is +// used only on deletion, to clear the SASL accounts and sender-map bindings of +// the domain's applications. func NewService(st *store.Store, odk *OpenDKIM, apps Applications, selectorDefault string) *Service { return &Service{store: st, odk: odk, apps: apps, selector: selectorDefault} } -// List returns all domains with application counts (spec 7.2.2). +// List returns all domains with application counts (product.md). func (s *Service) List() ([]store.Domain, error) { return s.store.ListDomains() } @@ -57,7 +60,7 @@ func (s *Service) Get(id int64) (store.Domain, error) { } // Add registers a new sending domain: it records the row, ensures a DKIM key -// exists on disk, and regenerates + reloads the OpenDKIM tables (spec 7.2.3). +// exists on disk, and regenerates + reloads the OpenDKIM tables (product.md). // name must already be normalised and validated by the caller. A duplicate // returns store.ErrDomainExists. // @@ -91,7 +94,7 @@ func (s *Service) rollbackAdd(id int64) { _ = s.store.DeleteDomain(id) } -// Delete removes a domain and everything bound to it (spec 7.2.4, 6.5). The +// Delete removes a domain and everything bound to it (product.md). The // order matters: the applications' SASL accounts are cleared first, while their // logins are still in the registry; then the registry rows (applications and // their addresses) go via the DB cascade; then the OpenDKIM tables and the @@ -107,8 +110,9 @@ func (s *Service) Delete(id int64) error { return fmt.Errorf("clear SASL accounts for %s: %w", d.Name, err) } // Drop the domain's own level-2 limit and those of its applications while the - // application rows still exist (the cleanup query joins them). rate_limits has - // no cascade of its own (ref_id is a plain integer, spec 7.4/9). + // application rows still exist (the cleanup query joins them). rate_limits + // has no cascade of its own (ref_id is a plain integer, README § Rate + // limiting; architecture.md § Persistence). if err := s.store.DeleteRateLimitsForDomain(id); err != nil { return fmt.Errorf("clear rate limits for %s: %w", d.Name, err) } @@ -129,19 +133,19 @@ func (s *Service) Delete(id int64) error { return nil } -// DKIMRecord returns the DNS TXT record to publish for a domain (spec 7.2.10). +// DKIMRecord returns the DNS TXT record to publish for a domain (product.md). func (s *Service) DKIMRecord(d store.Domain) (DKIMRecord, error) { return s.odk.Record(d.Name, d.DKIMSelector) } -// RateLimit returns the domain-level differentiated rate limit (spec 7.4), and -// whether one is configured, for the domain's edit form. +// RateLimit returns the domain-level differentiated rate limit (README § Rate +// limiting), and whether one is configured, for the domain's edit form. func (s *Service) RateLimit(domainID int64) (store.RateLimit, bool, error) { return s.store.GetRateLimit(store.RateLimitScopeDomain, domainID) } // SaveRateLimit stores the domain-level rate limit. The caller has validated the -// IPs and numbers (spec 7.6.2); the milter reads the row live, so no reload is +// IPs and numbers (security.md); the milter reads the row live, so no reload is // needed. func (s *Service) SaveRateLimit(domainID int64, ips []string, maxMessages, windowSeconds int) error { return s.store.SetRateLimit(store.RateLimit{ @@ -154,14 +158,15 @@ func (s *Service) SaveRateLimit(domainID int64, ips []string, maxMessages, windo } // ClearRateLimit removes the domain-level rate limit, falling back to level 1 -// only (spec 7.4). +// only (README § Rate limiting). func (s *Service) ClearRateLimit(domainID int64) error { return s.store.DeleteRateLimit(store.RateLimitScopeDomain, domainID) } -// Resync regenerates the OpenDKIM tables from the registry and reloads OpenDKIM. -// It backs the manual reload button (spec 7.2.12) and doubles as a recovery path -// if the tables ever drift from the database. +// Resync regenerates the OpenDKIM tables from the registry and reloads +// OpenDKIM. It backs the manual reload button (architecture.md § Panel HTTP +// surface) and doubles as a recovery path if the tables ever drift from the +// database. func (s *Service) Resync() error { return s.resync() } diff --git a/internal/domain/transfer.go b/internal/domain/transfer.go index 36809c7..300ed55 100644 --- a/internal/domain/transfer.go +++ b/internal/domain/transfer.go @@ -3,19 +3,20 @@ package domain import ( "fmt" - "codeberg.org/mix/selfpost/internal/buildinfo" - "codeberg.org/mix/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/buildinfo" + "github.com/mixeme/selfpost/internal/store" ) -// FormatDomainExport identifies a single-domain export file (spec 7.5.B). +// FormatDomainExport identifies a single-domain export file (architecture.md § +// Persistence). const FormatDomainExport = "selfpost-domain-export" // DomainExport is the serialisable form of one sending domain, for moving it -// between two independently running SelfPost instances (spec 7.5.B). It carries -// the DKIM private key (so the published DNS record stays valid) and each -// application's working password (so credentials transfer without regeneration). -// The file is therefore as sensitive as a full backup and must be handled as a -// secret. +// between two independently running SelfPost instances (architecture.md § +// Persistence). It carries the DKIM private key (so the published DNS record +// stays valid) and each application's working password (so credentials +// transfer without regeneration). The file is therefore as sensitive as a full +// backup and must be handled as a secret. type DomainExport struct { Format string `json:"format"` Version string `json:"version"` @@ -35,8 +36,8 @@ type AppExport struct { // Export builds the transferable representation of a domain: its DKIM key, its // selector and every application with its address mode and working password -// (spec 7.5.B). The returned struct is marshalled to JSON by the caller and -// offered as a secret download. +// (architecture.md § Persistence). The returned struct is marshalled to JSON +// by the caller and offered as a secret download. func (s *Service) Export(id int64) (DomainExport, error) { d, err := s.store.GetDomain(id) if err != nil { @@ -73,17 +74,18 @@ func (s *Service) Export(id int64) (DomainExport, error) { return exp, nil } -// Import re-creates a domain from an export file on this instance (spec 7.5.B): -// it stores the imported DKIM key (so the published DNS record needs no change), -// registers the domain and rebuilds the OpenDKIM tables, then re-creates each -// application with its working password and rebuilds the Postfix sender map. +// Import re-creates a domain from an export file on this instance +// (architecture.md § Persistence): it stores the imported DKIM key (so the +// published DNS record needs no change), registers the domain and rebuilds the +// OpenDKIM tables, then re-creates each application with its working password +// and rebuilds the Postfix sender map. // -// exp.Domain must already be normalised and validated by the caller (spec -// 7.6.2); the selector is checked for config-injection safety here. A domain or -// login that already exists is rejected (store.ErrDomainExists / -// store.ErrLoginExists) rather than merged. If any step fails, everything the -// import created is rolled back, so a partial import never leaves the instance -// in an inconsistent state. +// exp.Domain must already be normalised and validated by the caller +// (security.md); the selector is checked for +// config-injection safety here. A domain or login that already exists is +// rejected (store.ErrDomainExists / store.ErrLoginExists) rather than merged. +// If any step fails, everything the import created is rolled back, so a +// partial import never leaves the instance in an inconsistent state. func (s *Service) Import(exp DomainExport) (store.Domain, error) { if exp.Format != FormatDomainExport { return store.Domain{}, fmt.Errorf("not a SelfPost domain export (format %q)", exp.Format) diff --git a/internal/domain/transfer_test.go b/internal/domain/transfer_test.go index 7c3d159..98c8987 100644 --- a/internal/domain/transfer_test.go +++ b/internal/domain/transfer_test.go @@ -5,7 +5,7 @@ import ( "fmt" "testing" - "codeberg.org/mix/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/store" ) // fakeApps stands in for *app.Service in the domain-transfer tests: it records diff --git a/internal/health/cert.go b/internal/health/cert.go index f529af5..221253a 100644 --- a/internal/health/cert.go +++ b/internal/health/cert.go @@ -14,8 +14,8 @@ import ( const certWarnDays = 14 // Certificate is the state of the TLS certificate Postfix serves on 465/587 -// (spec 8: TLS_CERT_FILE). The panel only reads it — the file is supplied by -// the reverse proxy through a read-only mount. +// (README § Environment variables: TLS_CERT_FILE). The panel only reads it — +// the file is supplied by the reverse proxy through a read-only mount. type Certificate struct { Path string Subject string diff --git a/internal/health/processes.go b/internal/health/processes.go index bc8efd0..3bb4e74 100644 --- a/internal/health/processes.go +++ b/internal/health/processes.go @@ -25,11 +25,12 @@ type Process struct { Status Status } -// Processes returns the state of every supervised program (spec 4's three -// processes plus the reload/cert/logrotate helpers). +// Processes returns the state of every supervised program (architecture.md § +// Image and processes — the three processes plus the reload/cert/logrotate +// helpers). // // The command takes fixed arguments and no user input, so it never goes through -// a shell (spec 7.6.3). `supervisorctl status` deliberately exits non-zero when +// a shell (security.md). `supervisorctl status` deliberately exits non-zero when // some program is not running, so the output is parsed first and the exit status // only matters when nothing could be parsed from it. func Processes() ([]Process, error) { diff --git a/internal/logtail/logtail.go b/internal/logtail/logtail.go index 4829f3e..77590e8 100644 --- a/internal/logtail/logtail.go +++ b/internal/logtail/logtail.go @@ -1,9 +1,10 @@ // Package logtail follows Postfix's mail.log and reconciles the send-log -// delivery statuses the journal-milter could not know at receive time (spec -// 7.3). A milter row starts life as "queued"; Postfix only decides sent / -// deferred / bounced later, per recipient, and reports it in mail.log. This -// package parses those lines by queue-id + recipient and advances the matching -// rows, and prunes rows past the retention window. +// delivery statuses the journal-milter could not know at receive time +// (architecture.md § Persistence). A milter row starts life as "queued"; +// Postfix only decides sent / deferred / bounced later, per recipient, and +// reports it in mail.log. This package parses those lines by queue-id + +// recipient and advances the matching rows, and prunes rows past the retention +// window. package logtail import ( @@ -17,7 +18,7 @@ import ( "strings" "time" - "codeberg.org/mix/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/store" ) // StatusStore is the slice of the store the log-tailer needs: advancing @@ -39,7 +40,7 @@ const ( // startup). The window itself is configurable; the cadence need not be. retentionInterval = 6 * time.Hour // defaultRetentionDays applies when the configured value is unset/invalid - // (spec 7.3). + // (README § Environment variables: SEND_LOG_RETENTION_DAYS). defaultRetentionDays = 90 ) @@ -50,7 +51,13 @@ const ( // // The ": to=, …, status=" shape is specific to the // delivery agents; qmgr/smtpd/cleanup lines do not match. -var deliveryRe = regexp.MustCompile(`\b([0-9A-Za-z]+): to=<([^>]*)>,.*\bstatus=(\w+)`) +// +// The run before status= is lazy on purpose. Postfix appends the remote +// server's reply verbatim, so a greedy match would take the *last* status= on +// the line — and that one can come from the reply text, which the far end +// controls. A bounce whose reply quoted "status=sent" would then be filed as a +// success. The real field is always the first one after to=<…>. +var deliveryRe = regexp.MustCompile(`\b([0-9A-Za-z]+): to=<([^>]*)>,.*?\bstatus=(\w+)`) // parseDelivery extracts (queue-id, recipient, status) from a mail.log line. // ok is false for lines that are not recognised delivery results. @@ -124,10 +131,10 @@ func retentionLoop(ctx context.Context, st StatusStore, retentionDays int) { } // TailLines returns up to n of the most recent lines from path, for the -// panel's mail.log monitoring view (spec 7.2.13). It is a one-shot, -// point-in-time read on request — unrelated to the background follow loop -// above — that reads backwards in chunks so it stays cheap against a -// multi-megabyte log rather than reading the whole file every poll. +// panel's mail.log monitoring view (architecture.md § Panel HTTP surface). It +// is a one-shot, point-in-time read on request — unrelated to the background +// follow loop above — that reads backwards in chunks so it stays cheap against +// a multi-megabyte log rather than reading the whole file every poll. func TailLines(path string, n int) ([]string, error) { f, err := os.Open(path) if err != nil { diff --git a/internal/logtail/logtail_test.go b/internal/logtail/logtail_test.go index 62c3d47..5f6554b 100644 --- a/internal/logtail/logtail_test.go +++ b/internal/logtail/logtail_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "codeberg.org/mix/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/store" ) func TestParseDelivery(t *testing.T) { @@ -61,6 +61,59 @@ func TestParseDelivery(t *testing.T) { line: "host postfix/smtpd[10]: 41E862C00D9E: client=unknown[203.0.113.7]", wantOK: false, }, + { + // The remote server's reply is quoted verbatim at the end of the + // line and is entirely attacker-influenced text. A "status=" that + // appears in there must not win over the real field, or a bounce + // would be filed as a success. + name: "status= quoted in the remote reply does not win", + line: "host postfix/smtp[26]: 9F1A2C00D9E: to=, relay=mx.example.net[203.0.113.9]:25, dsn=5.1.1, status=bounced (host mx.example.net said: 550 5.1.1 unknown status=sent (in reply to RCPT TO command))", + wantOK: true, + queueID: "9F1A2C00D9E", + recipient: "a@example.net", + status: store.StatusBounced, + }, + { + // Postfix logs the null sender's own delivery (double bounce) with + // an empty recipient. It parses, and the empty recipient simply + // matches no send-log row — the panel only ever records mail it + // accepted from an authenticated client. + name: "null recipient parses with an empty address", + line: "host postfix/smtp[26]: A1B2C3: to=<>, relay=none, delay=0.1, dsn=2.0.0, status=sent (250 OK)", + wantOK: true, + queueID: "A1B2C3", + recipient: "", + status: store.StatusSent, + }, + { + // An alias/virtual expansion carries orig_to= as well; the address + // the message was actually delivered to is the one in to=. + name: "orig_to is ignored in favour of to", + line: "host postfix/lmtp[26]: 4Xk9tS1abcz: to=, orig_to=, relay=x, dsn=2.0.0, status=sent (ok)", + wantOK: true, + queueID: "4Xk9tS1abcz", + recipient: "real@example.net", + status: store.StatusSent, + }, + { + // Postfix's own delivery agents write these two, but neither is a + // final result we model: "deliverable" comes from address + // verification probes, and anything unrecognised is dropped rather + // than guessed at, leaving the row in its previous state. + name: "unknown status word is not a delivery result", + line: "host postfix/smtp[26]: BEEF01: to=, relay=x, status=deliverable (ok)", + wantOK: false, + }, + { + name: "status matching is case-sensitive, as Postfix writes it", + line: "host postfix/smtp[26]: BEEF02: to=, relay=x, dsn=4.0.0, status=Deferred (connect timed out)", + wantOK: false, + }, + { + name: "cleanup message-id line ignored", + line: "host postfix/cleanup[12]: BEEF03: message-id=", + wantOK: false, + }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { diff --git a/internal/logtail/offset.go b/internal/logtail/offset.go index f8c8000..0b965cd 100644 --- a/internal/logtail/offset.go +++ b/internal/logtail/offset.go @@ -9,7 +9,7 @@ import ( "os" "time" - "codeberg.org/mix/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/store" ) const ( diff --git a/internal/milter/milter.go b/internal/milter/milter.go index 2ae29d4..6c0807c 100644 --- a/internal/milter/milter.go +++ b/internal/milter/milter.go @@ -1,13 +1,14 @@ // Package milter implements the SelfPost journal-milter: a lightweight milter -// (spec 7.3) attached to Postfix's smtpd_milters alongside OpenDKIM. On the -// receive path it reads the SASL login, From, recipients and Subject of each -// accepted message and records one send-log row per (queue-id, recipient), -// giving the panel a structured, filterable history that raw mail.log cannot. +// (architecture.md § Mail path) attached to Postfix's smtpd_milters alongside +// OpenDKIM. On the receive path it reads the SASL login, From, recipients and +// Subject of each accepted message and records one send-log row per (queue-id, +// recipient), giving the panel a structured, filterable history that raw +// mail.log cannot. // // It is monitoring only: it never rejects, and every callback returns Continue // or Accept so a failure of this milter can never block the relay. Postfix is // configured with default_action=accept for this milter's socket, so even a -// crash or hang fails open (spec 7.3). +// crash or hang fails open (architecture.md § Mail path). package milter import ( @@ -21,13 +22,14 @@ import ( "github.com/emersion/go-milter" - "codeberg.org/mix/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/store" ) // Store is the persistence the milter needs on the receive path: recording -// accepted messages (spec 7.3) and, for level-2 rate limiting (spec 7.4), -// looking up the configured limits and counting recent messages. *store.Store -// satisfies it; tests substitute a fake. +// accepted messages (architecture.md § Mail path) and, for level-2 rate +// limiting (README § Rate limiting), looking up the configured limits and +// counting recent messages. *store.Store satisfies it; tests substitute a +// fake. type Store interface { InsertQueued(e store.SendLogEntry) error InsertRejected(e store.SendLogEntry) error @@ -37,11 +39,11 @@ type Store interface { // session accumulates the fields of one message as the milter callbacks fire. // Milter macros arrive per-stage and do not accumulate, so each value is -// captured at the stage that carries it (spec 7.3): SASL login -// and From at MAIL, each recipient at RCPT, Subject in the headers, and the -// queue-id at end-of-message. go-milter creates one session per connection; a -// connection may carry several messages, so per-message fields are reset at -// MailFrom (the start of every transaction). +// captured at the stage that carries it (architecture.md § Mail path): SASL +// login and From at MAIL, each recipient at RCPT, Subject in the headers, and +// the queue-id at end-of-message. go-milter creates one session per +// connection; a connection may carry several messages, so per-message fields +// are reset at MailFrom (the start of every transaction). type session struct { milter.NoOpMilter rec Store @@ -74,8 +76,8 @@ func (s *session) Connect(host, family string, port uint16, addr net.IP, m *milt // macros). This is also the earliest stage where both the sending domain (from // the sender) and the application (the login) are known, so the level-2 rate // limit is enforced here: over the limit, the message is refused with a 4xx -// tempfail before recipients are even offered (spec 7.4). Enforcement is -// fail-open — see overLimit. +// tempfail before recipients are even offered (README § Rate limiting). +// Enforcement is fail-open — see overLimit. func (s *session) MailFrom(from string, m *milter.Modifier) (milter.Response, error) { s.releaseReservations() // a previous transaction that ended without EOM/ABORT s.from = cleanAddress(from) @@ -161,7 +163,8 @@ func macro(m *milter.Modifier, name string) string { } // record writes one send-log row per recipient. Failures are logged, never -// propagated: journalling must not affect mail acceptance (spec 7.3). +// propagated: journalling must not affect mail acceptance (architecture.md § +// Mail path). func (s *session) record(queueID string) { domain := domainOf(s.from) rcpts := s.rcpts @@ -198,8 +201,9 @@ func cleanAddress(a string) string { } // domainOf returns the lower-cased domain of an email address, or "" if there -// is no domain part. Sender binding guarantees the From domain equals -// the application's domain, so this is the sending domain (spec 7.3). +// is no domain part. Sender binding guarantees the From domain equals the +// application's domain, so this is the sending domain (architecture.md § Mail +// path). func domainOf(addr string) string { if i := strings.LastIndexByte(addr, '@'); i >= 0 { return strings.ToLower(addr[i+1:]) diff --git a/internal/milter/milter_test.go b/internal/milter/milter_test.go index 9474e6b..76f3cc6 100644 --- a/internal/milter/milter_test.go +++ b/internal/milter/milter_test.go @@ -9,7 +9,7 @@ import ( "github.com/emersion/go-milter" - "codeberg.org/mix/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/store" ) // fakeRecorder captures inserts and can be made to fail, to prove the milter diff --git a/internal/milter/ratelimit.go b/internal/milter/ratelimit.go index 049c68a..418d4b8 100644 --- a/internal/milter/ratelimit.go +++ b/internal/milter/ratelimit.go @@ -4,19 +4,20 @@ import ( "log" "time" - "codeberg.org/mix/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/store" ) // overLimit reports whether the message currently being received should be -// refused under a level-2 differentiated limit (spec 7.4). It checks the -// domain-level and application-level limits in turn; either being exceeded is -// enough to refuse. +// refused under a level-2 differentiated limit (README § Rate limiting). It +// checks the domain-level and application-level limits in turn; either being +// exceeded is enough to refuse. // // It is deliberately fail-open: any store error, or the absence of a usable -// limit, is treated as "not over limit" so a malfunction of the level-2 limiter -// can never block mail — Postfix's level-1 anvil limit (spec 5) remains the -// backstop, and it does not depend on this milter at all. Only a clean count at -// or above a configured ceiling returns true. +// limit, is treated as "not over limit" so a malfunction of the level-2 +// limiter can never block mail — Postfix's level-1 anvil limit +// (architecture.md § Mail path) remains the backstop, and it does not depend +// on this milter at all. Only a clean count at or above a configured ceiling +// returns true. // // A message that passes reserves a slot per applicable limit, released once it // reaches the send log (or is abandoned) — see inflight for why the stored @@ -78,10 +79,10 @@ func (s *session) releaseReservations() { s.reserved = nil } -// recordRejected writes a send-log row for a message refused by a level-2 limit -// (spec 7.4, "опционально фиксирует ... для видимости в UI"), so the rejection -// shows up in the monitoring screen. Only MAIL-stage fields are known; the write -// is best-effort and never affects the response. +// recordRejected writes a send-log row for a message refused by a level-2 +// limit (README § Rate limiting — refusals are recorded too), so the rejection +// shows up in the monitoring screen. Only MAIL-stage fields are known; the +// write is best-effort and never affects the response. func (s *session) recordRejected() { err := s.rec.InsertRejected(store.SendLogEntry{ Domain: domainOf(s.from), diff --git a/internal/postfix/postfix.go b/internal/postfix/postfix.go index 8104859..996952b 100644 --- a/internal/postfix/postfix.go +++ b/internal/postfix/postfix.go @@ -1,7 +1,8 @@ // Package postfix owns the Postfix configuration files the panel edits at -// runtime and the privileged reload that applies them (spec 5.1, 7.6.3-4): the -// smtpd_sender_login_maps table binding each application's SASL login to the -// sender addresses it may use, plus the relay configuration in main.cf. +// runtime and the privileged reload that applies them (architecture.md § Mail +// path, security.md): the smtpd_sender_login_maps table binding each +// application's SASL login to the sender addresses it may use, plus the relay +// configuration in main.cf. package postfix import ( @@ -38,22 +39,24 @@ func (p *Postfix) SenderLoginMapsPath() string { return p.senderLoginMapsPath } -// Binding is one sender-address → login pair (spec 5.1). Address is either a -// domain wildcard "@example.com" or a specific address "alerts@example.com". +// Binding is one sender-address → login pair (architecture.md § Mail path). +// Address is either a domain wildcard "@example.com" or a specific address +// "alerts@example.com". type Binding struct { Address string Login string } // RebuildSenderLoginMaps regenerates the sender_login_maps file from the full -// set of bindings and reloads Postfix (spec 5.1). Full regeneration (rather than -// incremental edits) keeps the file a pure function of the registry, so add, -// edit and delete share one idempotent path. The file is written atomically -// before the reload. +// set of bindings and reloads Postfix (architecture.md § Mail path). Full +// regeneration (rather than incremental edits) keeps the file a pure function +// of the registry, so add, edit and delete share one idempotent path. The file +// is written atomically before the reload. // // Several applications may be authorised for the same address (many-to-one, -// spec 5.1 §4) — their logins are merged onto a single line as a comma-separated -// list, which is how Postfix expects multiple owners of one sender. +// architecture.md § Mail path) — their logins are merged onto a single line as +// a comma-separated list, which is how Postfix expects multiple owners of one +// sender. func (p *Postfix) RebuildSenderLoginMaps(bindings []Binding) error { content, err := renderSenderLoginMaps(bindings) if err != nil { @@ -66,7 +69,8 @@ func (p *Postfix) RebuildSenderLoginMaps(bindings []Binding) error { } // Reload asks Postfix to re-read its configuration without regenerating any -// file. It backs the panel's manual reload button (spec 7.2.12). +// file. It backs the panel's manual reload button (architecture.md § Panel +// HTTP surface). func (p *Postfix) Reload() error { return p.reload() } @@ -74,7 +78,7 @@ func (p *Postfix) Reload() error { // renderSenderLoginMaps builds the sender_login_maps file contents. Keys are // sorted for deterministic output and the logins under each key are sorted and // de-duplicated. Every address and login is re-checked for injection safety -// before being written (spec 7.6.4) — upstream validation already guarantees +// before being written (security.md) — upstream validation already guarantees // this, but the writer refuses to emit anything unsafe as a hard backstop. func renderSenderLoginMaps(bindings []Binding) ([]byte, error) { byAddr := make(map[string][]string) @@ -94,8 +98,8 @@ func renderSenderLoginMaps(bindings []Binding) ([]byte, error) { for _, addr := range order { logins := byAddr[addr] sort.Strings(logins) - // texthash format: . A comma-separated value - // lists every login permitted to use this sender (spec 5.1 §4). + // texthash format: . A comma-separated value lists + // every login permitted to use this sender (architecture.md § Mail path). fmt.Fprintf(&sb, "%s %s\n", addr, strings.Join(logins, ",")) } return []byte(sb.String()), nil @@ -113,9 +117,9 @@ func appendUnique(list []string, v string) []string { // assertMapSafe rejects any address/login value that could break out of a single // map line or inject a directive. Addresses are validated to a strict whitelist // (letters, digits, '@', '.', '-', '_', '+') and logins to an even stricter one -// upstream (spec 7.6.2); this is defence in depth against a validation gap ever +// upstream (security.md); this is defence in depth against a validation gap ever // letting whitespace, a newline or a comma (the value separator) through into -// the file (spec 7.6.4). +// the file (security.md). func assertMapSafe(address, login string) error { if address == "" || login == "" { return fmt.Errorf("postfix: empty address or login") @@ -130,17 +134,17 @@ func assertMapSafe(address, login string) error { } // reloadViaSupervisor asks supervisord (PID 1, running as root) to run the -// one-shot `postfix-reload` program, which executes the canonical -// `postfix reload` and re-reads main.cf/master.cf and the lookup tables they -// reference. The panel runs unprivileged: it cannot run `postfix reload` itself, -// and it cannot signal the Postfix master directly because `postfix start-fg` -// forks a separate master whose PID supervisord does not track (a SIGHUP to the +// one-shot `postfix-reload` program, which executes the canonical `postfix +// reload` and re-reads main.cf/master.cf and the lookup tables they reference. +// The panel runs unprivileged: it cannot run `postfix reload` itself, and it +// cannot signal the Postfix master directly because `postfix start-fg` forks a +// separate master whose PID supervisord does not track (a SIGHUP to the // supervised process would never reach it). Going through supervisord's // group-accessible control socket runs the reload as root without any panel -// privilege (spec 5.2, 7.2.12, 7.6.3, 7.6.8). +// privilege (architecture.md § Mail path, security.md). // // Arguments are fixed literals — no user input is interpolated into the command, -// and it never goes through a shell (spec 7.6.3). +// and it never goes through a shell (security.md). func reloadViaSupervisor() error { cmd := exec.Command("supervisorctl", "-c", "/etc/supervisor/supervisord.conf", diff --git a/internal/postfix/postfix_test.go b/internal/postfix/postfix_test.go index 4b70efc..04e5c88 100644 --- a/internal/postfix/postfix_test.go +++ b/internal/postfix/postfix_test.go @@ -7,7 +7,7 @@ import ( func TestRenderSenderLoginMaps(t *testing.T) { // Deliberately unsorted, with two logins sharing one wildcard key - // (many-to-one, spec 5.1 §4) to exercise merge + sort. + // (many-to-one, architecture.md § Mail path) to exercise merge + sort. bindings := []Binding{ {"@zeta.example", "z1"}, {"alerts@alpha.example", "a-listed"}, diff --git a/internal/postfix/queue.go b/internal/postfix/queue.go index d0df5a0..039b41a 100644 --- a/internal/postfix/queue.go +++ b/internal/postfix/queue.go @@ -6,12 +6,12 @@ import ( "strings" ) -// Queue returns Postfix's own human-readable mail-queue listing (spec 7.2.11): -// active, deferred and held messages, exactly as an administrator would see -// via the CLI. The command takes a single fixed flag and no user input, so it -// never goes through a shell (spec 7.6.3). The panel is responsible for -// escaping the output before display (spec 7.6.7); this function returns it -// as-is. +// Queue returns Postfix's own human-readable mail-queue listing +// (architecture.md § Panel HTTP surface): active, deferred and held messages, +// exactly as an administrator would see via the CLI. The command takes a +// single fixed flag and no user input, so it never goes through a shell +// (security.md). The panel is responsible for escaping the output before +// display (security.md); this function returns it as-is. func Queue() (string, error) { cmd := exec.Command("postqueue", "-p") out, err := cmd.CombinedOutput() diff --git a/internal/postfix/write.go b/internal/postfix/write.go index 6cc7ada..b79da1f 100644 --- a/internal/postfix/write.go +++ b/internal/postfix/write.go @@ -8,7 +8,7 @@ import ( // writeFileAtomic writes data to path via a temp file in the same directory // followed by a rename, so a concurrent Postfix reload only ever sees the -// complete old or new map, never a partial write (spec 7.6.4). It mirrors the +// complete old or new map, never a partial write (security.md). It mirrors the // same primitive used for the OpenDKIM tables. func writeFileAtomic(path string, data []byte, perm os.FileMode) error { dir := filepath.Dir(path) diff --git a/internal/store/admin.go b/internal/store/admin.go index 46d1565..31fcf77 100644 --- a/internal/store/admin.go +++ b/internal/store/admin.go @@ -10,7 +10,7 @@ import ( // ErrNoAdmin is returned by GetAdmin when primary setup has not happened yet. var ErrNoAdmin = errors.New("no administrator account") -// Admin is the single panel administrator (spec 7.6.1). +// Admin is the single panel administrator (security.md). type Admin struct { Username string PasswordHash string @@ -19,7 +19,7 @@ type Admin struct { // AdminExists reports whether the administrator account has been created. This // doubles as the "primary setup complete" flag: once true, the /setup route is -// permanently gone (spec 7.6.1). +// permanently gone (security.md). func (s *Store) AdminExists() (bool, error) { var n int if err := s.db.QueryRow("SELECT COUNT(*) FROM admin").Scan(&n); err != nil { diff --git a/internal/store/admin_test.go b/internal/store/admin_test.go index 6a3c485..c240f29 100644 --- a/internal/store/admin_test.go +++ b/internal/store/admin_test.go @@ -28,7 +28,7 @@ func TestUpdateAdmin(t *testing.T) { } // An update before setup must not create the account: only the one-time setup -// flow may do that (spec 7.6.1). +// flow may do that (security.md). func TestUpdateAdminWithoutAdmin(t *testing.T) { st := openTestStore(t) diff --git a/internal/store/applications.go b/internal/store/applications.go index a8efeed..dfe2577 100644 --- a/internal/store/applications.go +++ b/internal/store/applications.go @@ -8,23 +8,24 @@ import ( ) // ErrLoginExists is returned when an application login is already taken. The -// login is globally unique because it is the SASL identity Postfix authenticates -// (one sasldb2 across all domains, spec 5.1). +// login is globally unique because it is the SASL identity Postfix +// authenticates (one sasldb2 across all domains, architecture.md § Mail path). var ErrLoginExists = errors.New("application login already exists") // ErrApplicationNotFound is returned when an application id does not exist. var ErrApplicationNotFound = errors.New("application not found") -// Address modes (spec 4.1). Kept in sync with the CHECK constraint in the schema. +// Address modes (product.md § Multi-domain model). Kept in sync with the CHECK +// constraint in the schema. const ( AddressModeWildcard = "wildcard" // any address within the application's domain AddressModeList = "list" // only the explicitly listed addresses ) -// Application is a SASL account bound to a single domain (spec 4.1, 5.1). The -// password is never stored here — only in sasldb2, hashed — so it can be shown -// exactly once at creation/regeneration (spec 7.6.1). Addresses is populated only -// in 'list' mode. +// Application is a SASL account bound to a single domain (product.md § +// Multi-domain model). The password is never stored here — only in sasldb2, +// hashed — so it can be shown exactly once at creation/regeneration +// (security.md). Addresses is populated only in 'list' mode. type Application struct { ID int64 DomainID int64 @@ -35,9 +36,9 @@ type Application struct { } // Binding is one sender-address → login pair, as consumed by the -// smtpd_sender_login_maps generator (spec 5.1). For a wildcard application the -// Address is the domain wildcard "@example.com"; for a list application there is -// one Binding per listed address. +// smtpd_sender_login_maps generator (architecture.md § Mail path). For a +// wildcard application the Address is the domain wildcard "@example.com"; for +// a list application there is one Binding per listed address. type Binding struct { Address string Login string @@ -45,7 +46,7 @@ type Binding struct { // AddApplication inserts an application and, in list mode, its addresses, in a // single transaction. The caller must have validated login and every address -// (spec 7.6.2) beforehand; the query is parameterised regardless. A duplicate +// (security.md) beforehand; the query is parameterised regardless. A duplicate // login maps to ErrLoginExists. func (s *Store) AddApplication(domainID int64, login, mode string, addresses []string) (Application, error) { now := time.Now().UTC() @@ -82,7 +83,7 @@ func (s *Store) AddApplication(domainID int64, login, mode string, addresses []s } // UpdateApplicationMode switches an application's address mode and replaces its -// address list atomically (spec 7.2.7). The login and password are untouched. +// address list atomically (product.md). The login and password are untouched. // Returns ErrApplicationNotFound if the id does not exist. func (s *Store) UpdateApplicationMode(id int64, mode string, addresses []string) error { tx, err := s.db.Begin() @@ -163,7 +164,7 @@ func (s *Store) GetApplication(id int64) (Application, error) { } // ListApplicationsByDomain returns a domain's applications ordered by login, -// each with its address list populated (spec 7.2.6). +// each with its address list populated (product.md). func (s *Store) ListApplicationsByDomain(domainID int64) ([]Application, error) { rows, err := s.db.Query( "SELECT id, domain_id, login, address_mode, created_at FROM applications WHERE domain_id = ? ORDER BY login", @@ -196,8 +197,9 @@ func (s *Store) ListApplicationsByDomain(domainID int64) ([]Application, error) } // ListApplicationLogins returns every application login across all domains, -// ordered, for the send-log monitoring screen's filter dropdown (spec 7.2). -// Logins are globally unique (spec 5.1), so no domain qualifier is needed. +// ordered, for the send-log monitoring screen's filter dropdown (product.md). +// Logins are globally unique (architecture.md § Mail path), so no domain +// qualifier is needed. func (s *Store) ListApplicationLogins() ([]string, error) { rows, err := s.db.Query("SELECT login FROM applications ORDER BY login") if err != nil { @@ -218,7 +220,7 @@ func (s *Store) ListApplicationLogins() ([]string, error) { // ListLoginsByDomain returns the SASL logins of a domain's applications. Used to // purge sasldb2 entries before a domain (and its applications via cascade) is -// deleted, while the logins are still known (spec 7.2.4). +// deleted, while the logins are still known (product.md). func (s *Store) ListLoginsByDomain(domainID int64) ([]string, error) { rows, err := s.db.Query("SELECT login FROM applications WHERE domain_id = ? ORDER BY login", domainID) if err != nil { @@ -237,10 +239,11 @@ func (s *Store) ListLoginsByDomain(domainID int64) ([]string, error) { return out, rows.Err() } -// ListBindings returns every sender-address → login pair across all domains, the -// raw material for the smtpd_sender_login_maps file (spec 5.1). Wildcard -// applications yield a single "@domain" binding; list applications yield one -// binding per address. Ordered deterministically so the generated map is stable. +// ListBindings returns every sender-address → login pair across all domains, +// the raw material for the smtpd_sender_login_maps file (architecture.md § +// Mail path). Wildcard applications yield a single "@domain" binding; list +// applications yield one binding per address. Ordered deterministically so the +// generated map is stable. func (s *Store) ListBindings() ([]Binding, error) { rows, err := s.db.Query(` SELECT '@' || d.name, a.login @@ -271,7 +274,7 @@ func (s *Store) ListBindings() ([]Binding, error) { // DeleteApplication removes an application and its addresses (via cascade), // returning the deleted application so the caller can drop its sasldb2 entry -// (spec 7.2.8). Returns ErrApplicationNotFound if no such row existed. +// (product.md). Returns ErrApplicationNotFound if no such row existed. func (s *Store) DeleteApplication(id int64) (Application, error) { a, err := s.GetApplication(id) if err != nil { diff --git a/internal/store/domains.go b/internal/store/domains.go index fba6ce1..a370476 100644 --- a/internal/store/domains.go +++ b/internal/store/domains.go @@ -16,9 +16,10 @@ var ErrDomainExists = errors.New("domain already exists") // ErrDomainNotFound is returned when a domain id/name does not exist. var ErrDomainNotFound = errors.New("domain not found") -// Domain is a sending domain managed through the panel (spec 4.1). The DKIM key -// material itself lives on disk under /data; this row records the selector and -// metadata. AppCount is populated by the listing queries, not stored. +// Domain is a sending domain managed through the panel (product.md § +// Multi-domain model). The DKIM key material itself lives on disk under /data; +// this row records the selector and metadata. AppCount is populated by the +// listing queries, not stored. type Domain struct { ID int64 Name string @@ -28,7 +29,7 @@ type Domain struct { } // AddDomain inserts a new sending domain. The caller is responsible for having -// validated name (spec 7.6.2) before it reaches SQL; the query is parameterised +// validated name (security.md) before it reaches SQL; the query is parameterised // regardless. A duplicate name maps to ErrDomainExists. func (s *Store) AddDomain(name, selector string) (Domain, error) { now := time.Now().UTC() @@ -49,7 +50,7 @@ func (s *Store) AddDomain(name, selector string) (Domain, error) { return Domain{ID: id, Name: name, DKIMSelector: selector, CreatedAt: now}, nil } -// ListDomains returns every domain with its bound-application count (spec 7.2.2), +// ListDomains returns every domain with its bound-application count (product.md), // ordered by name. func (s *Store) ListDomains() ([]Domain, error) { rows, err := s.db.Query(` @@ -92,7 +93,7 @@ func (s *Store) GetDomain(id int64) (Domain, error) { } // DeleteDomain removes a domain. Its applications and their address/binding rows -// go with it via ON DELETE CASCADE (spec 7.2.4). Returns ErrDomainNotFound if no +// go with it via ON DELETE CASCADE (product.md). Returns ErrDomainNotFound if no // such row existed. func (s *Store) DeleteDomain(id int64) error { res, err := s.db.Exec("DELETE FROM domains WHERE id = ?", id) diff --git a/internal/store/ratelimits.go b/internal/store/ratelimits.go index 37bb3fd..a2d6010 100644 --- a/internal/store/ratelimits.go +++ b/internal/store/ratelimits.go @@ -9,23 +9,25 @@ import ( "time" ) -// Rate-limit scopes (spec 7.4). A level-2 limit is attached either to a domain -// (counted across all its applications and IPs) or to a single application. +// Rate-limit scopes (README § Rate limiting). A level-2 limit is attached +// either to a domain (counted across all its applications and IPs) or to a +// single application. const ( RateLimitScopeDomain = "domain" RateLimitScopeApp = "application" ) -// RateLimit is a differentiated level-2 rate limit (spec 7.4): an optional set -// of expected client IPs plus a message ceiling over a sliding window, attached -// to a domain or an application. It is enforced in the journal-milter; level 1 -// (Postfix anvil, spec 5) is the IP backstop that always applies even when this -// is absent or the milter is down. +// RateLimit is a differentiated level-2 rate limit (README § Rate limiting): +// an optional set of expected client IPs plus a message ceiling over a sliding +// window, attached to a domain or an application. It is enforced in the +// journal-milter; level 1 (Postfix anvil, architecture.md § Mail path) is the +// IP backstop that always applies even when this is absent or the milter is +// down. // -// Both the IP binding and the ceiling are optional in the schema, but a limit is -// only enforced when it is Active(): the design deliberately allows an admin to -// leave the IP binding empty for apps that send from changing IPs, in which case -// only level 1 protects them (spec 7.4's caveat). +// Both the IP binding and the ceiling are optional in the schema, but a limit +// is only enforced when it is Active(): the design deliberately allows an +// admin to leave the IP binding empty for apps that send from changing IPs, in +// which case only level 1 protects them (README § Rate limiting). type RateLimit struct { Scope string RefID int64 @@ -35,8 +37,9 @@ type RateLimit struct { } // Active reports whether the limit is fully configured and should be enforced. -// A missing IP binding, ceiling or window leaves the differentiated limit inert -// (spec 7.4): the IP binding is what scopes the limit to a known sender. +// A missing IP binding, ceiling or window leaves the differentiated limit +// inert (README § Rate limiting): the IP binding is what scopes the limit to a +// known sender. func (r RateLimit) Active() bool { return len(r.AllowedIPs) > 0 && r.MaxMessages > 0 && r.WindowSeconds > 0 } @@ -76,7 +79,7 @@ func (s *Store) GetRateLimit(scope string, refID int64) (RateLimit, bool, error) } // SetRateLimit upserts the level-2 limit for a domain or application. The caller -// (panel) has already validated the IPs and numbers (spec 7.6.2); values are +// (panel) has already validated the IPs and numbers (security.md); values are // stored via bound parameters and read back live by the milter. func (s *Store) SetRateLimit(rl RateLimit) error { _, err := s.db.Exec( @@ -148,12 +151,13 @@ func (s *Store) RateLimit(scope, ref string) (RateLimit, bool, error) { return rl, true, nil } -// CountMessages returns how many distinct messages the reference (a domain name -// or an application login) has queued since t, for the level-2 sliding window -// (spec 7.4). It counts distinct queue-ids — one message with many recipients is -// one message, matching level 1's per-message semantics — and excludes rows that -// were themselves rejected by a limit (they were never sent). It reuses the send -// log the journal already writes (spec 7.4: "переиспользует данные журнала"). +// CountMessages returns how many distinct messages the reference (a domain +// name or an application login) has queued since t, for the level-2 sliding +// window (README § Rate limiting). It counts distinct queue-ids — one message +// with many recipients is one message, matching level 1's per-message +// semantics — and excludes rows that were themselves rejected by a limit (they +// were never sent). It reuses the send log the journal already writes (README +// § Rate limiting — the limiter reuses the send log). func (s *Store) CountMessages(scope, ref string, since time.Time) (int64, error) { var column string switch scope { diff --git a/internal/store/sendlog.go b/internal/store/sendlog.go index 0ef0faa..5abeda1 100644 --- a/internal/store/sendlog.go +++ b/internal/store/sendlog.go @@ -6,23 +6,25 @@ import ( "time" ) -// Send-log status values (spec 7.3). "queued" is written by the journal-milter -// when a message is accepted; the log-tailer advances it to one of the final -// states as Postfix reports delivery per recipient. +// Send-log status values (architecture.md § Persistence). "queued" is written +// by the journal-milter when a message is accepted; the log-tailer advances it +// to one of the final states as Postfix reports delivery per recipient. const ( StatusQueued = "queued" StatusSent = "sent" StatusDeferred = "deferred" StatusBounced = "bounced" // StatusRejected marks a message the journal-milter refused with a 4xx under - // a level-2 rate limit (spec 7.4). Such a row never gets a queue-id and is - // excluded from the level-2 message count (it was never sent). + // a level-2 rate limit (README § Rate limiting). Such a row never gets a + // queue-id and is excluded from the level-2 message count (it was never + // sent). StatusRejected = "rejected" ) // SendLogEntry is a single queued send-log row. The journal-milter creates one -// per (queue-id, recipient) pair at end-of-message (spec 7.3.3); every field -// except the status/timestamps comes from the accepted message. +// per (queue-id, recipient) pair at end-of-message (architecture.md § +// Persistence); every field except the status/timestamps comes from the +// accepted message. type SendLogEntry struct { QueueID string Domain string @@ -34,8 +36,8 @@ type SendLogEntry struct { // InsertQueued records an accepted message in the send log with status // "queued". It is called from the journal-milter hot path, so it returns any -// error for the caller to log rather than deciding policy here; the milter must -// stay fail-open regardless (spec 7.3). +// error for the caller to log rather than deciding policy here; the milter +// must stay fail-open regardless (architecture.md § Persistence). func (s *Store) InsertQueued(e SendLogEntry) error { now := time.Now().UTC().Format(time.RFC3339) _, err := s.db.Exec( @@ -51,9 +53,10 @@ func (s *Store) InsertQueued(e SendLogEntry) error { } // InsertRejected records a message the journal-milter refused under a level-2 -// rate limit (spec 7.4), so the rejection is visible in the send-log UI. Only -// the fields known at MAIL FROM are set (domain, sender, app login); there is no -// queue-id or recipient because the message was rejected before it was queued. +// rate limit (README § Rate limiting), so the rejection is visible in the +// send-log UI. Only the fields known at MAIL FROM are set (domain, sender, app +// login); there is no queue-id or recipient because the message was rejected +// before it was queued. func (s *Store) InsertRejected(e SendLogEntry) error { now := time.Now().UTC().Format(time.RFC3339) _, err := s.db.Exec( @@ -88,9 +91,9 @@ func (s *Store) UpdateStatus(queueID, recipient, status string) (int64, error) { return n, nil } -// SendLogRow is one row as returned to the monitoring UI (spec 7.2, 7.3.3): a -// SendLogEntry plus the fields that only exist once a row has been written -// (id, current status, timestamps). +// SendLogRow is one row as returned to the monitoring UI (architecture.md § +// Persistence): a SendLogEntry plus the fields that only exist once a row has +// been written (id, current status, timestamps). type SendLogRow struct { ID int64 QueueID string @@ -111,7 +114,7 @@ type SendLogFilter struct { } // QuerySendLog returns send-log rows matching filter, newest first, for the -// monitoring screen's server-side pagination (spec 7.2's send-log view). +// monitoring screen's server-side pagination (product.md's send-log view). func (s *Store) QuerySendLog(filter SendLogFilter, limit, offset int) ([]SendLogRow, error) { where, args := sendLogWhere(filter) args = append(args, limit, offset) @@ -171,10 +174,11 @@ func sendLogWhere(f SendLogFilter) (string, []any) { return " WHERE " + strings.Join(clauses, " AND "), args } -// DeleteSendLogBefore removes send-log rows created before cutoff, implementing -// the configurable retention window (spec 7.3, SEND_LOG_RETENTION_DAYS). It -// returns the number of rows pruned. created_at is stored as RFC3339 UTC, so a -// lexical comparison against the same format is chronologically correct. +// DeleteSendLogBefore removes send-log rows created before cutoff, +// implementing the configurable retention window (architecture.md § +// Persistence, SEND_LOG_RETENTION_DAYS). It returns the number of rows pruned. +// created_at is stored as RFC3339 UTC, so a lexical comparison against the +// same format is chronologically correct. func (s *Store) DeleteSendLogBefore(cutoff time.Time) (int64, error) { res, err := s.db.Exec( `DELETE FROM send_log WHERE created_at < ?`, diff --git a/internal/store/sendlog_test.go b/internal/store/sendlog_test.go index b6f00d8..9947c80 100644 --- a/internal/store/sendlog_test.go +++ b/internal/store/sendlog_test.go @@ -40,7 +40,8 @@ func readSendLog(t *testing.T, s *Store) []sendLogRow { func TestInsertQueuedAndUpdateStatus(t *testing.T) { st := openTestStore(t) - // Two recipients on the same queue-id → two independent rows (spec 7.3.3). + // Two recipients on the same queue-id → two independent rows (architecture.md + // § Persistence). for _, to := range []string{"a@example.net", "b@example.net"} { if err := st.InsertQueued(SendLogEntry{ QueueID: "ABC123", diff --git a/internal/store/store.go b/internal/store/store.go index e9b22c0..4434eba 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -1,7 +1,7 @@ // Package store owns the SelfPost SQLite database: the single file under /data // that persists the administrator account, sending domains and applications, -// the send log and rate-limit settings (spec 9). It exposes typed queries so -// the rest of the panel never builds SQL by hand. +// the send log and rate-limit settings (architecture.md § Persistence). It +// exposes typed queries so the rest of the panel never builds SQL by hand. package store import ( diff --git a/internal/web/handlers_account.go b/internal/web/handlers_account.go index 845359d..8533d34 100644 --- a/internal/web/handlers_account.go +++ b/internal/web/handlers_account.go @@ -5,15 +5,15 @@ import ( "net/http" "strings" - "codeberg.org/mix/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/store" "golang.org/x/crypto/bcrypt" ) // handleAccount serves the administrator's own account settings: the username -// and password chosen during setup are the only panel credentials (spec 7.6.1), -// and until now they could be changed only by recreating the state. Changing -// them here never touches application SASL logins, which are a separate -// identity system (spec 5.1). +// and password chosen during setup are the only panel credentials +// (security.md), and until now they could be changed only by recreating the +// state. Changing them here never touches application SASL logins, which are a +// separate identity system (architecture.md § Mail path). func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: @@ -58,7 +58,7 @@ func accountFlash(r *http.Request) string { // is always required, so a stolen session alone cannot lock the administrator // out of their own panel, and the attempt is throttled on the same limiter as // the login form so this route cannot be used to brute-force the password past -// that limit (spec 7.6.5). +// that limit (security.md). func (s *Server) submitAccount(w http.ResponseWriter, r *http.Request) { if !s.loginLimiter.Allow(clientIP(r, s.trustedProxies)) { s.renderAccount(w, r, http.StatusTooManyRequests, diff --git a/internal/web/handlers_apps.go b/internal/web/handlers_apps.go index 8d0e67e..700280d 100644 --- a/internal/web/handlers_apps.go +++ b/internal/web/handlers_apps.go @@ -7,13 +7,13 @@ import ( "strconv" "strings" - "codeberg.org/mix/selfpost/internal/dnscheck" - "codeberg.org/mix/selfpost/internal/domain" - "codeberg.org/mix/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/dnscheck" + "github.com/mixeme/selfpost/internal/domain" + "github.com/mixeme/selfpost/internal/store" ) // newCred carries a freshly generated login/password to the template so it can -// be shown exactly once (spec 7.6.1). It is never read back from storage. +// be shown exactly once (security.md). It is never read back from storage. type newCred struct { Login string Password string @@ -29,8 +29,9 @@ type detailView struct { FormMode string FormAddrs string NewCred *newCred - // RateLimitErr surfaces a validation error from a domain- or application-level - // rate-limit form (spec 7.4) as a page banner. + // RateLimitErr surfaces a validation error from a domain- or + // application-level rate-limit form (README § Rate limiting) as a page + // banner. RateLimitErr string // ExportErr surfaces a rejected encryption password from the export card. ExportErr string @@ -47,9 +48,9 @@ type appRateLimitView struct { WindowVal string // window seconds, defaulted when unset } -// handleDomainDetail shows a single domain: its DKIM DNS record (spec 7.2.10) +// handleDomainDetail shows a single domain: its DKIM DNS record (product.md) // and its applications with the controls to add, edit, delete and re-issue -// credentials (spec 7.2.5-9). +// credentials (product.md). func (s *Server) handleDomainDetail(w http.ResponseWriter, r *http.Request) { d, ok := s.lookupDomain(w, r) if !ok { @@ -214,7 +215,7 @@ func detailFlash(r *http.Request) string { } // handleAddApplication creates an application on a domain and renders the page -// back with the generated password shown once (spec 7.2.5, 7.6.1). Because the +// back with the generated password shown once (product.md, security.md). Because the // password cannot be recovered later, this deliberately renders inline rather // than redirecting. func (s *Server) handleAddApplication(w http.ResponseWriter, r *http.Request) { @@ -253,7 +254,7 @@ func (s *Server) handleAddApplication(w http.ResponseWriter, r *http.Request) { }) } -// handleUpdateAppMode switches an application's address mode / list (spec 7.2.7). +// handleUpdateAppMode switches an application's address mode / list (product.md). func (s *Server) handleUpdateAppMode(w http.ResponseWriter, r *http.Request) { a, ok := s.lookupApplication(w, r) if !ok { @@ -282,7 +283,7 @@ func (s *Server) handleUpdateAppMode(w http.ResponseWriter, r *http.Request) { } // handleRegenPassword issues a new password for an application and shows it once -// (spec 7.2.9, 7.6.1). Rendered inline, like creation, so the password is visible. +// (product.md, security.md). Rendered inline, like creation, so the password is visible. func (s *Server) handleRegenPassword(w http.ResponseWriter, r *http.Request) { a, ok := s.lookupApplication(w, r) if !ok { @@ -306,7 +307,7 @@ func (s *Server) handleRegenPassword(w http.ResponseWriter, r *http.Request) { } // handleDeleteApplication removes an application and returns to its domain page -// (spec 7.2.8). +// (product.md). func (s *Server) handleDeleteApplication(w http.ResponseWriter, r *http.Request) { a, ok := s.lookupApplication(w, r) if !ok { @@ -343,7 +344,7 @@ func (s *Server) lookupApplication(w http.ResponseWriter, r *http.Request) (stor // splitAddresses turns the textarea/field input (addresses separated by // newlines, commas or whitespace) into a raw slice. Normalisation and -// validation happen in the app service (spec 7.6.2). +// validation happen in the app service (security.md). func splitAddresses(s string) []string { return strings.FieldsFunc(s, func(r rune) bool { return r == '\n' || r == '\r' || r == ',' || r == ' ' || r == '\t' || r == ';' diff --git a/internal/web/handlers_auth.go b/internal/web/handlers_auth.go index fad3c41..72c546e 100644 --- a/internal/web/handlers_auth.go +++ b/internal/web/handlers_auth.go @@ -5,7 +5,7 @@ import ( "net/http" "strings" - "codeberg.org/mix/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/store" "golang.org/x/crypto/bcrypt" ) @@ -127,7 +127,7 @@ func (s *Server) renderLogin(w http.ResponseWriter, status int, formErr string) } func (s *Server) submitLogin(w http.ResponseWriter, r *http.Request) { - // Brute-force throttle by client IP (spec 7.6.5). + // Brute-force throttle by client IP (security.md). if !s.loginLimiter.Allow(clientIP(r, s.trustedProxies)) { s.renderLogin(w, http.StatusTooManyRequests, "Too many attempts. Please wait and try again.") return diff --git a/internal/web/handlers_backup.go b/internal/web/handlers_backup.go index f3a19a8..4b1d45f 100644 --- a/internal/web/handlers_backup.go +++ b/internal/web/handlers_backup.go @@ -9,10 +9,10 @@ import ( "net/http" "time" - "codeberg.org/mix/selfpost/internal/backup" - "codeberg.org/mix/selfpost/internal/domain" - "codeberg.org/mix/selfpost/internal/secretfile" - "codeberg.org/mix/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/backup" + "github.com/mixeme/selfpost/internal/domain" + "github.com/mixeme/selfpost/internal/secretfile" + "github.com/mixeme/selfpost/internal/store" ) // maxImportBytes caps a domain-import upload. A domain export is a small JSON @@ -30,7 +30,7 @@ func (s *Server) handleBackupPage(w http.ResponseWriter, r *http.Request) { } // renderBackupPage draws the page; importErr surfaces a failed domain import -// (spec 7.5.B) next to the form that produced it. +// (architecture.md § Persistence) next to the form that produced it. func (s *Server) renderBackupPage(w http.ResponseWriter, r *http.Request, status int, importErr string) { s.renderBackupPageWith(w, r, status, importErr, "") } @@ -50,14 +50,14 @@ func (s *Server) renderBackupPageWith(w http.ResponseWriter, r *http.Request, st }) } -// handleBackup streams a full-server backup as a download (spec 7.5.A). It is an -// authenticated admin action (this handler sits behind the auth middleware). The -// archive carries DKIM private keys, the admin password hash and SASL -// credentials, so it is served with no-store and as an attachment to discourage -// caching of secret material. When the operator ticks "encrypt with a -// password", the archive is wrapped in a .spbk envelope on the way out, so the -// file that lands on their disk — wherever it is copied afterwards — is useless -// without the password. +// handleBackup streams a full-server backup as a download (architecture.md § +// Persistence). It is an authenticated admin action (this handler sits behind +// the auth middleware). The archive carries DKIM private keys, the admin +// password hash and SASL credentials, so it is served with no-store and as an +// attachment to discourage caching of secret material. When the operator ticks +// "encrypt with a password", the archive is wrapped in a .spbk envelope on the +// way out, so the file that lands on their disk — wherever it is copied +// afterwards — is useless without the password. func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request) { password, pwErr := secretFilePassword(r) if pwErr != "" { @@ -113,12 +113,12 @@ func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request) { } } -// handleExportDomain streams a single-domain export as a secret download (spec -// 7.5.B). Like the full backup it is POST-only (state is not changed, but the -// response contains the domain's DKIM private key and application passwords, so -// it must not be prefetchable or cached). Like the full backup it can be -// encrypted with a password, in which case the download is a .spde envelope -// instead of plain JSON. +// handleExportDomain streams a single-domain export as a secret download +// (architecture.md § Persistence). Like the full backup it is POST-only (state +// is not changed, but the response contains the domain's DKIM private key and +// application passwords, so it must not be prefetchable or cached). Like the +// full backup it can be encrypted with a password, in which case the download +// is a .spde envelope instead of plain JSON. func (s *Server) handleExportDomain(w http.ResponseWriter, r *http.Request) { d, ok := s.lookupDomain(w, r) if !ok { @@ -175,11 +175,12 @@ func (s *Server) handleExportDomain(w http.ResponseWriter, r *http.Request) { } // handleImportDomain accepts an uploaded domain-export file and re-creates the -// domain on this instance (spec 7.5.B). The domain name is normalised and -// validated here (spec 7.6.2); the domain service validates the selector, each -// login and address, and the DKIM key before writing anything. On success it -// redirects to the new domain's page; on failure it re-renders the backup page, -// where the import form lives, with a friendly message. +// domain on this instance (architecture.md § Persistence). The domain name is +// normalised and validated here (security.md); the domain service validates +// the selector, each login and address, and the DKIM key before writing +// anything. On success it redirects to the new domain's page; on failure it +// re-renders the backup page, where the import form lives, with a friendly +// message. func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxImportBytes) if err := r.ParseMultipartForm(maxImportBytes); err != nil { @@ -243,7 +244,7 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) { } // Normalise and validate the domain name before it reaches the service, the - // same gate the add-domain form uses (spec 7.6.2). + // same gate the add-domain form uses (security.md). exp.Domain = normalizeDomain(exp.Domain) if err := validateDomain(exp.Domain); err != nil { s.renderBackupPage(w, r, http.StatusBadRequest, "Invalid domain in export file: "+err.Error()) diff --git a/internal/web/handlers_backup_test.go b/internal/web/handlers_backup_test.go index f6bbd7b..d9b3492 100644 --- a/internal/web/handlers_backup_test.go +++ b/internal/web/handlers_backup_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "codeberg.org/mix/selfpost/internal/secretfile" + "github.com/mixeme/selfpost/internal/secretfile" ) // postForm builds the kind of request the backup and export forms submit. diff --git a/internal/web/handlers_domains.go b/internal/web/handlers_domains.go index 5dbd29a..88eae4c 100644 --- a/internal/web/handlers_domains.go +++ b/internal/web/handlers_domains.go @@ -6,12 +6,12 @@ import ( "net/http" "strconv" - "codeberg.org/mix/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/store" ) // handleDashboard is the authenticated landing page: the list of sending // domains with their DKIM/selector and application counts, plus the add-domain -// form (spec 7.2.2). +// form (product.md). func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { s.renderDashboard(w, r, http.StatusOK, "", "") } @@ -48,7 +48,7 @@ func dashboardFlash(r *http.Request) string { // handleAddDomain validates the submitted name, creates the domain (DKIM key + // OpenDKIM reload), and redirects to the domain's page so the DNS record to -// publish is shown (spec 7.2.3). +// publish is shown (product.md). func (s *Server) handleAddDomain(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { s.renderDashboard(w, r, http.StatusBadRequest, "Invalid form submission.", "") @@ -76,7 +76,7 @@ func (s *Server) handleAddDomain(w http.ResponseWriter, r *http.Request) { } // handleDeleteConfirm shows the cascade warning before a domain is removed: the -// panel must explicitly state that all bound applications go with it (spec 7.2.4). +// panel must explicitly state that all bound applications go with it (product.md). func (s *Server) handleDeleteConfirm(w http.ResponseWriter, r *http.Request) { d, ok := s.lookupDomain(w, r) if !ok { @@ -116,11 +116,12 @@ func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) { } // handleReload re-applies both the OpenDKIM configuration and the Postfix -// sender map on demand (spec 7.2.12). Each Resync regenerates its files from the -// database and reloads its daemon, so the button doubles as a drift-recovery. -// The button lives on the status page: it is a "put the daemons -// back in the state the database describes" action, which belongs with the rest -// of the server-health screen rather than in the domain list's top bar. +// sender map on demand (architecture.md § Panel HTTP surface). Each Resync +// regenerates its files from the database and reloads its daemon, so the +// button doubles as a drift-recovery. The button lives on the status page: it +// is a "put the daemons back in the state the database describes" action, +// which belongs with the rest of the server-health screen rather than in the +// domain list's top bar. func (s *Server) handleReload(w http.ResponseWriter, r *http.Request) { if err := s.domains.Resync(); err != nil { logf("panel: manual reload (opendkim): %v", err) diff --git a/internal/web/handlers_monitor.go b/internal/web/handlers_monitor.go index 3708a0d..1d353a2 100644 --- a/internal/web/handlers_monitor.go +++ b/internal/web/handlers_monitor.go @@ -6,12 +6,12 @@ import ( "net/http" "strconv" - "codeberg.org/mix/selfpost/internal/logtail" - "codeberg.org/mix/selfpost/internal/postfix" - "codeberg.org/mix/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/logtail" + "github.com/mixeme/selfpost/internal/postfix" + "github.com/mixeme/selfpost/internal/store" ) -// sendLogPageSize bounds each send-log page (spec 7.2's monitoring screens +// sendLogPageSize bounds each send-log page (product.md's monitoring screens // call for pagination); logTailLines bounds how much of mail.log the log view // shows per refresh. const ( @@ -20,10 +20,10 @@ const ( ) // handleDeliveries renders the Deliveries page over the send log: server-side -// filters by domain/application and pagination (spec 7.3.3). The row table -// itself is the "deliveries_rows" fragment, shared verbatim with -// handleDeliveriesRows so the initial page and its HTMX-polled refreshes never -// diverge. +// filters by domain/application and pagination (architecture.md § +// Persistence). The row table itself is the "deliveries_rows" fragment, shared +// verbatim with handleDeliveriesRows so the initial page and its HTMX-polled +// refreshes never diverge. func (s *Server) handleDeliveries(w http.ResponseWriter, r *http.Request) { data, err := s.sendLogData(r) if err != nil { @@ -38,7 +38,8 @@ func (s *Server) handleDeliveries(w http.ResponseWriter, r *http.Request) { } // handleDeliveriesRows serves the HTMX polling fragment for the delivery table -// (spec 7.1: fragment endpoints return HTML, not JSON). +// (architecture.md § Panel HTTP surface: fragment endpoints return HTML, not +// JSON). func (s *Server) handleDeliveriesRows(w http.ResponseWriter, r *http.Request) { data, err := s.sendLogData(r) if err != nil { @@ -110,7 +111,8 @@ func parsePage(v string) int { return n } -// handleMailQueue renders the Mail queue page (spec 7.2.11). +// handleMailQueue renders the Mail queue page (architecture.md § Panel HTTP +// surface). func (s *Server) handleMailQueue(w http.ResponseWriter, r *http.Request) { out, errText := readQueue() s.render(w, http.StatusOK, "mail_queue", map[string]any{ @@ -143,7 +145,8 @@ func readQueue() (string, string) { return out, "" } -// handleSystemLog renders the System log page over mail.log (spec 7.2.13). +// handleSystemLog renders the System log page over mail.log (architecture.md § +// Panel HTTP surface). func (s *Server) handleSystemLog(w http.ResponseWriter, r *http.Request) { lines, errText := s.readLogTail() s.render(w, http.StatusOK, "system_log", map[string]any{ diff --git a/internal/web/handlers_ratelimit.go b/internal/web/handlers_ratelimit.go index a9cd9b0..021d875 100644 --- a/internal/web/handlers_ratelimit.go +++ b/internal/web/handlers_ratelimit.go @@ -7,17 +7,18 @@ import ( "strconv" "strings" - "codeberg.org/mix/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/store" ) -// defaultRateLimitWindowSeconds is the sliding-window length used when an admin -// sets a message ceiling but leaves the window blank (spec 7.4, matching the -// level-1 default hour, spec 8: RATE_LIMIT_WINDOW_SECONDS). +// defaultRateLimitWindowSeconds is the sliding-window length used when an +// admin sets a message ceiling but leaves the window blank (README § Rate +// limiting, matching the level-1 default hour; README § Environment variables: +// RATE_LIMIT_WINDOW_SECONDS). const defaultRateLimitWindowSeconds = 3600 -// rateLimitInput is the validated result of a rate-limit form submission. clear -// means "remove the differentiated limit" (spec 7.4: an empty IP binding leaves -// only level 1). +// rateLimitInput is the validated result of a rate-limit form submission. +// clear means "remove the differentiated limit" (README § Rate limiting: an +// empty IP binding leaves only level 1). type rateLimitInput struct { clear bool ips []string @@ -25,10 +26,10 @@ type rateLimitInput struct { windowSeconds int } -// parseRateLimitForm validates a rate-limit submission on the server (spec -// 7.6.2). It returns clear=true when the admin removes the limit or leaves the -// IP binding empty; otherwise it requires a positive ceiling and window. The -// returned error's message is safe to show to the admin. +// parseRateLimitForm validates a rate-limit submission on the server +// (security.md). It returns clear=true when the admin removes the limit or +// leaves the IP binding empty; otherwise it requires a positive ceiling and +// window. The returned error's message is safe to show to the admin. func parseRateLimitForm(r *http.Request) (rateLimitInput, error) { if err := r.ParseForm(); err != nil { return rateLimitInput{}, fmt.Errorf("invalid form submission") @@ -41,7 +42,8 @@ func parseRateLimitForm(r *http.Request) (rateLimitInput, error) { return rateLimitInput{}, err } if len(ips) == 0 { - // No IP binding: the differentiated limit does not apply (spec 7.4). + // No IP binding: the differentiated limit does not apply (README § Rate + // limiting). return rateLimitInput{clear: true}, nil } maxMessages, err := parsePositiveInt(r.PostFormValue("max_messages"), 0) @@ -57,7 +59,7 @@ func parseRateLimitForm(r *http.Request) (rateLimitInput, error) { // parseIPList parses the allowed-IP field (IPs separated by newlines, commas or // whitespace) into a deduplicated list of canonical addresses, rejecting any -// token that is not a valid IP (spec 7.6.2). The values are only ever stored as +// token that is not a valid IP (security.md). The values are only ever stored as // SQLite parameters and compared in the milter, never written to a config file. func parseIPList(raw string) ([]string, error) { fields := strings.FieldsFunc(raw, func(r rune) bool { @@ -89,8 +91,9 @@ func parsePositiveInt(raw string, def int) (int, error) { return strconv.Atoi(raw) } -// handleDomainRateLimit saves or clears a domain-level differentiated rate limit -// (spec 7.4). No reload is needed — the milter reads the row live. +// handleDomainRateLimit saves or clears a domain-level differentiated rate +// limit (README § Rate limiting). No reload is needed — the milter reads the +// row live. func (s *Server) handleDomainRateLimit(w http.ResponseWriter, r *http.Request) { d, ok := s.lookupDomain(w, r) if !ok { @@ -113,7 +116,7 @@ func (s *Server) handleDomainRateLimit(w http.ResponseWriter, r *http.Request) { } // handleAppRateLimit saves or clears an application-level differentiated rate -// limit (spec 7.4). +// limit (README § Rate limiting). func (s *Server) handleAppRateLimit(w http.ResponseWriter, r *http.Request) { a, ok := s.lookupApplication(w, r) if !ok { diff --git a/internal/web/handlers_setup.go b/internal/web/handlers_setup.go index 1505f75..06fcf2a 100644 --- a/internal/web/handlers_setup.go +++ b/internal/web/handlers_setup.go @@ -8,11 +8,11 @@ import ( ) // handleSetup serves the one-time administrator creation flow at -// /setup/ (spec 7.6.1). Once an administrator exists the whole route +// /setup/ (security.md). Once an administrator exists the whole route // returns 404; an invalid or expired token is indistinguishable from a missing // page, also 404. func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) { - // Route-specific rate limit, separate from login (spec 7.6.1). + // Route-specific rate limit, separate from login (security.md). if !s.setupLimiter.Allow(clientIP(r, s.trustedProxies)) { http.Error(w, "too many requests", http.StatusTooManyRequests) return @@ -92,7 +92,7 @@ func (s *Server) submitSetup(w http.ResponseWriter, r *http.Request, token strin return } - // Setup is now permanently complete: burn the token (spec 7.6.1). + // Setup is now permanently complete: burn the token (security.md). s.setup.complete() logf("panel: administrator %q created; setup link is now disabled", username) http.Redirect(w, r, "/login", http.StatusSeeOther) diff --git a/internal/web/handlers_status.go b/internal/web/handlers_status.go index 875d678..031a1de 100644 --- a/internal/web/handlers_status.go +++ b/internal/web/handlers_status.go @@ -4,7 +4,7 @@ import ( "net/http" "strings" - "codeberg.org/mix/selfpost/internal/health" + "github.com/mixeme/selfpost/internal/health" ) // handleStatus renders the server status page: the panel's landing page and the @@ -26,7 +26,8 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { } // handleStatusFragment serves the HTMX polling fragment for the local checks -// (spec 7.1: fragment endpoints return HTML, not JSON). +// (architecture.md § Panel HTTP surface: fragment endpoints return HTML, not +// JSON). func (s *Server) handleStatusFragment(w http.ResponseWriter, _ *http.Request) { s.renderFragment(w, http.StatusOK, "status_body", s.statusBody()) } @@ -90,9 +91,10 @@ func (s *Server) statusBody() map[string]any { } } -// queueSummary reduces postqueue's listing to the one line worth showing on the -// status page; the full listing has its own screen (spec 7.2.11). postqueue -// prints either "Mail queue is empty" or a trailing "-- N Kbytes in M Requests." +// queueSummary reduces postqueue's listing to the one line worth showing on +// the status page; the full listing has its own screen (architecture.md § +// Panel HTTP surface). postqueue prints either "Mail queue is empty" or a +// trailing "-- N Kbytes in M Requests." func queueSummary(out string) string { lines := strings.Split(strings.TrimSpace(out), "\n") for i := len(lines) - 1; i >= 0; i-- { diff --git a/internal/web/ratelimit.go b/internal/web/ratelimit.go index c0dde2b..664ad82 100644 --- a/internal/web/ratelimit.go +++ b/internal/web/ratelimit.go @@ -6,7 +6,7 @@ import ( ) // rateLimiter is a simple fixed-window per-key counter used to throttle the -// setup and login routes (spec 7.6.1, 7.6.5). Keys are client IPs. It is not a +// setup and login routes (security.md). Keys are client IPs. It is not a // precise sliding window — a coarse backstop against brute-force and log noise // is all these routes need. type rateLimiter struct { diff --git a/internal/web/security.go b/internal/web/security.go index 9767a3d..d3b9120 100644 --- a/internal/web/security.go +++ b/internal/web/security.go @@ -20,7 +20,7 @@ import ( // - form-action 'self' keeps a form from being retargeted at another host. // // This is a second line of defence: XSS is already prevented by -// html/template's contextual auto-escaping (spec 7.6.7). +// html/template's contextual auto-escaping (security.md). const contentSecurityPolicy = "default-src 'self'; " + "object-src 'none'; " + "base-uri 'none'; " + diff --git a/internal/web/session.go b/internal/web/session.go index b61c316..daa0a8f 100644 --- a/internal/web/session.go +++ b/internal/web/session.go @@ -5,7 +5,7 @@ import ( "encoding/hex" "time" - "codeberg.org/mix/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/store" ) // renewThreshold bounds how often an active session's expiry is written back @@ -16,7 +16,7 @@ const renewThreshold = time.Hour // sessionStore persists login sessions in the database (plan B.1): a login // survives a container restart or redeploy. Only the SHA-256 of the token is -// stored, never the token itself (spec 7.6.6's crypto-random bearer token), so +// stored, never the token itself (security.md's crypto-random bearer token), so // a stolen database file or backup archive cannot be replayed as a session — // it only extends the login of whichever browser still holds the original // cookie. diff --git a/internal/web/session_test.go b/internal/web/session_test.go index cb381c7..3644d26 100644 --- a/internal/web/session_test.go +++ b/internal/web/session_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "codeberg.org/mix/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/store" ) func newTestSessionStore(t *testing.T) *sessionStore { diff --git a/internal/web/setup.go b/internal/web/setup.go index e386cdf..e162f05 100644 --- a/internal/web/setup.go +++ b/internal/web/setup.go @@ -7,17 +7,17 @@ import ( "sync" "time" - "codeberg.org/mix/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/store" ) -// setupTokenTTL is the lifetime of a setup token (spec 7.6.1). After it +// setupTokenTTL is the lifetime of a setup token (security.md). After it // elapses the token is regenerated and re-announced on the next /setup hit. const setupTokenTTL = 10 * time.Minute // setupManager owns the one-time administrator setup token. The token itself is // ephemeral (regenerated on restart or expiry) and lives only in memory; the // persistent "setup complete" fact is the presence of the admin row in the -// store, so once that exists the token is gone for good (spec 7.6.1). +// store, so once that exists the token is gone for good (security.md). type setupManager struct { store *store.Store hostname string @@ -71,8 +71,8 @@ func (m *setupManager) activeToken() (string, bool) { // validate reports whether provided matches the active token, using a // constant-time comparison to avoid leaking a correct prefix via timing -// (spec 7.6.1). A mismatch does NOT regenerate or invalidate the token: failed -// attempts must not let an attacker DoS a legitimate setup (spec 7.6.1). +// (security.md). A mismatch does NOT regenerate or invalidate the token: failed +// attempts must not let an attacker DoS a legitimate setup (security.md). func (m *setupManager) validate(provided string) bool { token, ok := m.activeToken() if !ok { @@ -94,13 +94,13 @@ func (m *setupManager) complete() { // regenerateLocked mints a fresh token, announces it and mirrors it to disk. // Caller holds m.mu. func (m *setupManager) regenerateLocked() { - m.token = randomToken(16) // 128 bits of entropy (spec 7.6.1) + m.token = randomToken(16) // 128 bits of entropy (security.md) m.expiresAt = time.Now().Add(setupTokenTTL) m.announce(m.token) } // announce prints the setup link to the container log and writes it to the -// token file so it can be read either way (spec 7.6.1). +// token file so it can be read either way (security.md). func (m *setupManager) announce(token string) { url := m.setupURL(token) logf("panel: ==================================================================") diff --git a/internal/web/static/favicon.svg b/internal/web/static/favicon.svg index 9e714db..f197cf3 100644 --- a/internal/web/static/favicon.svg +++ b/internal/web/static/favicon.svg @@ -1,5 +1,5 @@ - diff --git a/internal/web/static/logo-compact.svg b/internal/web/static/logo-compact.svg index 3c03928..9676b21 100644 --- a/internal/web/static/logo-compact.svg +++ b/internal/web/static/logo-compact.svg @@ -1,5 +1,5 @@ - diff --git a/internal/web/static/logo.svg b/internal/web/static/logo.svg index cb46ba3..3054766 100644 --- a/internal/web/static/logo.svg +++ b/internal/web/static/logo.svg @@ -1,5 +1,5 @@ - diff --git a/internal/web/templates.go b/internal/web/templates.go index 03aab27..f035364 100644 --- a/internal/web/templates.go +++ b/internal/web/templates.go @@ -9,21 +9,23 @@ import ( // templates holds the parsed page and fragment templates. Each page is parsed // together with the shared base layout so {{ template "base" . }} works. -// Fragments (HTMX polling targets, spec 7.1) are parsed standalone, without -// the layout, so they can be swapped into an existing page as an HTML snippet -// rather than a full document. Rendering always goes through html/template, -// which auto-escapes all interpolated data regardless (spec 7.6.7). +// Fragments (HTMX polling targets, architecture.md § Panel HTTP surface) are +// parsed standalone, without the layout, so they can be swapped into an +// existing page as an HTML snippet rather than a full document. Rendering +// always goes through html/template, which auto-escapes all interpolated data +// regardless (security.md). type templates struct { pages map[string]*template.Template fragments map[string]*template.Template } // pageFiles maps a logical page name to its template files. Every page -// composes with layout.html; pages that embed a polling fragment (spec 7.1) -// list that fragment's file too, so the same {{define}} block renders both -// the initial page and the fragment's own refresh responses identically. Pages -// sharing a block of markup (the encryption fields on the two secret downloads) -// list that partial the same way. +// composes with layout.html; pages that embed a polling fragment +// (architecture.md § Panel HTTP surface) list that fragment's file too, so the +// same {{define}} block renders both the initial page and the fragment's own +// refresh responses identically. Pages sharing a block of markup (the +// encryption fields on the two secret downloads) list that partial the same +// way. var pageFiles = map[string][]string{ "setup": {"templates/setup.html"}, "login": {"templates/login.html"}, @@ -101,7 +103,8 @@ func (s *Server) render(w http.ResponseWriter, status int, page string, data any } // renderFragment writes an HTMX polling fragment as a bare HTML snippet, with -// no surrounding layout (spec 7.1: fragment endpoints return HTML, not JSON). +// no surrounding layout (architecture.md § Panel HTTP surface: fragment +// endpoints return HTML, not JSON). func (s *Server) renderFragment(w http.ResponseWriter, status int, name string, data any) { tmpl, ok := s.tmpl.fragments[name] if !ok { diff --git a/internal/web/templates_test.go b/internal/web/templates_test.go index c6e2468..63dfa89 100644 --- a/internal/web/templates_test.go +++ b/internal/web/templates_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "codeberg.org/mix/selfpost/internal/health" + "github.com/mixeme/selfpost/internal/health" ) // The navigation is rendered from the layout, not copied into each page, so diff --git a/internal/web/token.go b/internal/web/token.go index 6e9ba7e..56b578f 100644 --- a/internal/web/token.go +++ b/internal/web/token.go @@ -7,7 +7,7 @@ import ( // randomToken returns a URL-safe token with at least nBytes*8 bits of entropy // drawn from crypto/rand. Setup and session tokens both use this; the setup -// token needs >=128 bits (spec 7.6.1), so callers pass nBytes >= 16. +// token needs >=128 bits (security.md), so callers pass nBytes >= 16. // // It panics if the system RNG fails: that is unrecoverable and must never be // papered over with a weak fallback for a security token. diff --git a/internal/web/validate.go b/internal/web/validate.go index 3ed4dc7..a53483b 100644 --- a/internal/web/validate.go +++ b/internal/web/validate.go @@ -7,7 +7,7 @@ import ( ) // minAdminPasswordLen is the floor for the administrator password. The panel is -// public (spec 7.6), so this is deliberately not tiny. +// public (security.md), so this is deliberately not tiny. const minAdminPasswordLen = 12 const ( @@ -21,7 +21,7 @@ const ( // weaker "any password is better than none". const minSecretFilePasswordLen = minAdminPasswordLen -// validateUsername enforces a strict server-side whitelist (spec 7.6.2): +// validateUsername enforces a strict server-side whitelist (security.md): // letters, digits, dot, dash, underscore. Client validation is never trusted. func validateUsername(u string) error { if len(u) < minUsernameLen || len(u) > maxUsernameLen { @@ -58,7 +58,7 @@ func normalizeDomain(name string) string { } // validateDomain enforces a strict server-side whitelist for sending-domain -// names (spec 7.6.2). The result is safe to write verbatim into the OpenDKIM +// names (security.md). The result is safe to write verbatim into the OpenDKIM // KeyTable/SigningTable and to use as a filesystem path segment: only // lower-case letters, digits, '.' and '-' are allowed, in valid DNS label // shape. Input must already be normalised with normalizeDomain. diff --git a/internal/web/web.go b/internal/web/web.go index a21a678..b23b3f7 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -1,6 +1,6 @@ // Package web implements the SelfPost control panel's HTTP surface: the -// one-time administrator setup flow (spec 7.6.1), login/session handling -// (spec 7.6.5-6) and the authenticated shell the later phases build on. +// one-time administrator setup flow (security.md), login/session handling +// (security.md) and the authenticated shell the later phases build on. package web import ( @@ -11,11 +11,11 @@ import ( "strings" "time" - "codeberg.org/mix/selfpost/internal/app" - "codeberg.org/mix/selfpost/internal/dnscheck" - "codeberg.org/mix/selfpost/internal/domain" - "codeberg.org/mix/selfpost/internal/health" - "codeberg.org/mix/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/app" + "github.com/mixeme/selfpost/internal/dnscheck" + "github.com/mixeme/selfpost/internal/domain" + "github.com/mixeme/selfpost/internal/health" + "github.com/mixeme/selfpost/internal/store" ) //go:embed templates/*.html static/* @@ -24,24 +24,26 @@ var assetsFS embed.FS // Config holds the panel's HTTP-facing configuration. type Config struct { // Hostname is the server's external hostname, used to build the absolute - // setup link shown in the logs (spec 7.6.1, 8: SELFPOST_HOSTNAME). + // setup link shown in the logs (security.md; README § Environment + // variables for SELFPOST_HOSTNAME). Hostname string // CookieSecure sets the Secure attribute on the session cookie. It defaults - // to true (spec 7.6.6); it exists as a knob only so the panel can be tested + // to true (security.md); it exists as a knob only so the panel can be tested // over plain HTTP in development, never for production. CookieSecure bool // SubmissionEnabled mirrors SUBMISSION_ENABLE: whether this deployment also // runs the 587/STARTTLS submission listener next to the primary 465 one - // (spec 5). The panel only reports it on the domain page's connection - // settings; it is a deploy-time flag, not something the panel can verify. + // (architecture.md § Mail path). The panel only reports it on the domain + // page's connection settings; it is a deploy-time flag, not something the + // panel can verify. SubmissionEnabled bool // MailLogPath is where Postfix's delivery log lives, read by the mail.log - // monitoring view (spec 7.2.13). It is the same path the log-tailer role - // follows in cmd/panel. + // monitoring view (architecture.md § Panel HTTP surface). It is the same path + // the log-tailer role follows in cmd/panel. MailLogPath string // DataDir and DBPath locate the persistent state a full backup archives - // (spec 7.5.A); Version is stamped into the backup manifest. They mirror the - // panel's own configuration. + // (architecture.md § Persistence); Version is stamped into the backup + // manifest. They mirror the panel's own configuration. DataDir string DBPath string Version string @@ -51,8 +53,9 @@ type Config struct { // honoured, so the header can't be spoofed by anyone but a trusted proxy. // Empty (the default) keeps rate-limiting keyed on RemoteAddr only. TrustedProxyCIDRs []*net.IPNet - // TLSCertFile is the certificate Postfix serves on 465/587 (spec 8), read - // read-only by the status page to report how much validity is left. + // TLSCertFile is the certificate Postfix serves on 465/587 (README § + // Environment variables), read read-only by the status page to report how + // much validity is left. TLSCertFile string // OpenDKIMSocket and JournalSocket are the two milter sockets Postfix // connects to. The status page stats them: the first is required for mail @@ -89,9 +92,10 @@ type Server struct { } // New builds the panel server. setupTokenPath is where the current setup token -// is mirrored on disk (spec 7.6.1); domains is the sending-domain service that -// owns DKIM keys and the OpenDKIM tables (spec 6); apps owns application SASL -// accounts and the Postfix sender map (spec 5.1). +// is mirrored on disk (security.md); domains is the sending-domain service +// that owns DKIM keys and the OpenDKIM tables (architecture.md § OpenDKIM); +// apps owns application SASL accounts and the Postfix sender map +// (architecture.md § Mail path). func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config, setupTokenPath string) (*Server, error) { tmpl, err := loadTemplates() if err != nil { @@ -113,9 +117,9 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config // round of lookups. dns: dnscheck.New(cfg.DNSResolvers), // Setup: a handful of attempts per minute per IP is plenty for a - // legitimate admin and blunts automated probing (spec 7.6.1). + // legitimate admin and blunts automated probing (security.md). setupLimiter: newRateLimiter(10, time.Minute), - // Login: throttle brute-force by IP (spec 7.6.5). + // Login: throttle brute-force by IP (security.md). loginLimiter: newRateLimiter(10, 15*time.Minute), trustedProxies: cfg.TrustedProxyCIDRs, @@ -125,7 +129,7 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config } // Start performs first-run bootstrapping: if there is no administrator yet, it -// generates and announces the setup link (spec 7.6.1). Safe to call once at +// generates and announces the setup link (security.md). Safe to call once at // server startup. func (s *Server) Start() error { return s.setup.bootstrap() @@ -141,7 +145,7 @@ func (s *Server) Handler() http.Handler { // Vendored static assets (HTMX). Served from the embedded FS. mux.Handle("/static/", http.FileServer(http.FS(assetsFS))) - // One-time administrator setup (spec 7.6.1). + // One-time administrator setup (security.md). mux.HandleFunc("/setup/", s.handleSetup) // Authentication. @@ -179,13 +183,14 @@ func (s *Server) Handler() http.Handler { // Administrator's own panel credentials. authed.HandleFunc("/account", s.handleAccount) - // Backup and migration: the page with both actions (spec 7.5.A-B), and the - // full-server backup download itself. + // Backup and migration: the page with both actions (architecture.md § + // Persistence-B), and the full-server backup download itself. authed.HandleFunc("GET /backup", s.handleBackupPage) authed.HandleFunc("POST /backup", s.handleBackup) - // Monitoring screens (spec 7.2.11-13): each page and its HTMX polling - // fragment (spec 7.1 — the /rows and /body endpoints return HTML, not JSON). + // Monitoring screens (architecture.md § Panel HTTP surface): each page and + // its HTMX polling fragment (architecture.md § Panel HTTP surface — the /rows + // and /body endpoints return HTML, not JSON). authed.HandleFunc("GET /deliveries", s.handleDeliveries) authed.HandleFunc("GET /deliveries/rows", s.handleDeliveriesRows) authed.HandleFunc("GET /mail-queue", s.handleMailQueue) diff --git a/test/e2e/go.mod b/test/e2e/go.mod index e88d05d..152a607 100644 --- a/test/e2e/go.mod +++ b/test/e2e/go.mod @@ -1,4 +1,4 @@ -module codeberg.org/mix/selfpost/test/e2e +module github.com/mixeme/selfpost/test/e2e go 1.26 diff --git a/test/e2e/negative_test.go b/test/e2e/negative_test.go index c828be9..e44a283 100644 --- a/test/e2e/negative_test.go +++ b/test/e2e/negative_test.go @@ -68,8 +68,8 @@ func testLevel2RateLimit(t *testing.T, sc *scenario) { } // testSenderLoginMismatch is plan C.4 negative check 2: an authenticated -// application cannot send as a sender it does not own (spec 5.1 p.3, -// reject_sender_login_mismatch) — the core anti-spoofing control. +// application cannot send as a sender it does not own (architecture.md § Mail +// path, reject_sender_login_mismatch) — the core anti-spoofing control. func testSenderLoginMismatch(t *testing.T, sc *scenario) { res := attemptSend(sendAttempt{ authLogin: sc.appLogin, authPassword: sc.appPassword, @@ -118,10 +118,10 @@ func testForeignRelayRejected(t *testing.T, sc *scenario) { } // testJournalMilterFailOpen is plan C.4 negative check 6: the journal-milter -// is monitoring-only and fails open (spec 7.3) — stopping the panel process -// (which owns the milter socket) must not block mail, and must not crash the -// container (crashexit only fires on PROCESS_STATE_FATAL, a clean supervisor -// stop is STOPPED, see build/crashexit.py). +// is monitoring-only and fails open (architecture.md § Mail path) — stopping +// the panel process (which owns the milter socket) must not block mail, and +// must not crash the container (crashexit only fires on PROCESS_STATE_FATAL, a +// clean supervisor stop is STOPPED, see build/crashexit.py). func testJournalMilterFailOpen(t *testing.T, sc *scenario) { if _, err := h.execIn("selfpost", "supervisorctl", "-c", "/etc/supervisor/supervisord.conf", "stop", "panel"); err != nil { t.Fatalf("stop panel: %v", err) @@ -196,12 +196,12 @@ func testSessionSurvivesRestart(t *testing.T, sc *scenario) { } // testLevel1RateLimit is plan C.4 negative check 4: the native Postfix anvil -// backstop (smtpd_client_message_rate_limit, spec 5 p.5), set by the override -// to RATE_LIMIT_MESSAGES_PER_IP=50, rejects once exceeded. It uses a dedicated -// application with no level-2 limit of its own, and retries well past that -// count, so the result is unambiguous regardless of how much of the shared -// per-IP budget earlier subtests already spent (they stay well under 50 -// between them). +// backstop (smtpd_client_message_rate_limit, README § Rate limiting), set by +// the override to RATE_LIMIT_MESSAGES_PER_IP=50, rejects once exceeded. It +// uses a dedicated application with no level-2 limit of its own, and retries +// well past that count, so the result is unambiguous regardless of how much of +// the shared per-IP budget earlier subtests already spent (they stay well +// under 50 between them). func testLevel1RateLimit(t *testing.T, sc *scenario) { login, password, err := sc.panel.addApplication(sc.domainID, "l1app", "wildcard", "") if err != nil { diff --git a/test/e2e/panel_client.go b/test/e2e/panel_client.go index f536678..066a46f 100644 --- a/test/e2e/panel_client.go +++ b/test/e2e/panel_client.go @@ -145,7 +145,7 @@ func (c *panelClient) dkimRecord(domainID string) (name, value string, err error } // addApplication submits the add-application form and returns the one-shot -// login/password the panel renders inline (spec 7.6.1 — never recoverable +// login/password the panel renders inline (security.md — never recoverable // later, so this is the only place to read it). func (c *panelClient) addApplication(domainID, login, mode, addresses string) (appLogin, password string, err error) { resp, body, err := c.postForm("/domains/"+domainID+"/applications", url.Values{ @@ -170,8 +170,9 @@ func (c *panelClient) addApplication(domainID, login, mode, addresses string) (a return appLogin, password, nil } -// setRateLimit saves a level-2 differentiated limit (spec 7.4) on either a -// domain (/domains/{id}/ratelimit) or an application (/applications/{id}/ratelimit). +// setRateLimit saves a level-2 differentiated limit (README § Rate limiting) +// on either a domain (/domains/{id}/ratelimit) or an application +// (/applications/{id}/ratelimit). func (c *panelClient) setRateLimit(path, allowedIP string, maxMessages, windowSeconds int) error { resp, body, err := c.postForm(path, url.Values{ "allowed_ips": {allowedIP},