From 995bd5db844535f1460a2f6555a66ff457cf9c2c Mon Sep 17 00:00:00 2001 From: Mikhail Yenuchenko Date: Wed, 5 Aug 2026 00:33:49 +0300 Subject: [PATCH] =?UTF-8?q?docs:=20D6-D9=20=E2=80=94=20HEALTHCHECK,=20env?= =?UTF-8?q?=20regression=20test,=20new=20docs,=20archive=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Docker HEALTHCHECK and mail-path /healthz liveness; env-doc regression test; architecture.md and development.md; product.md and expanded security.md; retire live specification.md to docs/archive/. Co-Authored-By: Claude --- CHANGELOG.md | 18 +++ README.md | 9 ++ build/Dockerfile | 6 + cmd/panel/envdoc_test.go | 133 ++++++++++++++++ docs/architecture.md | 142 ++++++++++++++++++ .../specification-v1.0.md} | 5 + docs/development.md | 130 ++++++++++++++++ docs/documentation-plan.md | 16 +- docs/implementation-plan.md | 2 +- docs/product.md | 102 +++++++++++++ docs/progress.md | 7 +- docs/roadmap.md | 2 +- docs/security.md | 83 ++++++++-- internal/health/health_test.go | 25 +++ internal/health/liveness.go | 46 ++++++ internal/web/web.go | 5 + 16 files changed, 703 insertions(+), 28 deletions(-) create mode 100644 cmd/panel/envdoc_test.go create mode 100644 docs/architecture.md rename docs/{specification.md => archive/specification-v1.0.md} (99%) create mode 100644 docs/development.md create mode 100644 docs/product.md create mode 100644 internal/health/liveness.go diff --git a/CHANGELOG.md b/CHANGELOG.md index fb23902..ab03cc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ### Added +- docs (D6): Docker `HEALTHCHECK` probes `/healthz`; endpoint returns 503 unless + opendkim, panel, and postfix are RUNNING (`internal/health.Liveness`). +- docs (D6): README *Container health* — scope of `/healthz` vs authenticated Status. +- docs (D7): `cmd/panel/envdoc_test.go` — regression test that every + `loadConfig` and build-script env key is listed in README documentation. +- docs (D8): `docs/architecture.md` — as-built processes, mail path, routes, + persistence (verified against code). +- docs (D8): `docs/development.md` — local Go workflow, `make e2e`, dev-server + loop, commit/CHANGELOG protocol, agent rules. +- docs (D9): `docs/product.md` — product purpose, assumptions, out-of-scope, + multi-domain model. +- docs (D9): `docs/security.md` — self-contained mandatory security checklist + (former spec §7.6). +- docs (D9): `specification.md` archived to + `docs/archive/specification-v1.0.md`; live docs updated (`progress.md`, + `implementation-plan.md`, `roadmap.md`, `documentation-plan.md`). - docs (D1): README *Operations* — panel screens (`/status`, domains, deliveries, mail queue, system log, backup, account), upgrade procedure, session behaviour (sliding idle, monitoring polls do not extend, password @@ -22,6 +38,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ### Changed +- `/healthz` now checks supervisord mail-path processes, not HTTP alone. +- `build/Dockerfile`: `curl` for `HEALTHCHECK`; probe on port 8080. - docs (D3): README backup — stopped-container `tar` of `./data` (with live-container WAL warning), `manifest.json` consumed after a matching restore. - docs (D4): README status banner (v1.0 implemented, links to open questions and diff --git a/README.md b/README.md index b457917..2dc37c9 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,15 @@ release, then `docker compose up -d`. The backup version check requires the running image to match the version that created a full backup — see [Fixed image tag](#fixed-image-tag). +**Container health.** The image declares a Docker `HEALTHCHECK` that probes +`GET /healthz` on port 8080 (unauthenticated). It returns `200 ok` when +OpenDKIM, the panel, and Postfix are all `RUNNING` under supervisord; +otherwise `503 unhealthy`. This catches a dead mail path that would still leave +the HTTP server up, but it does **not** verify TLS certificates, DNS records, +or end-to-end delivery — use the authenticated **Status** page for that. External +monitoring can use the same endpoint through the reverse proxy if you expose it, +or poll `docker inspect` health state on the host. + ## Rate limiting SelfPost applies two independent limits; both can refuse a submission, but only diff --git a/build/Dockerfile b/build/Dockerfile index d45394f..3205088 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -50,6 +50,7 @@ RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \ supervisor \ logrotate \ ca-certificates \ + curl \ && rm -rf /var/lib/apt/lists/* # Unprivileged user for the panel process (spec 7.6.8). @@ -104,6 +105,11 @@ RUN chmod +x /usr/local/bin/postfix-wrapper.sh /usr/local/bin/postfix-config.sh # which needs no inbound listener or EXPOSE. EXPOSE 8080 465 587 +# Liveness probe: panel HTTP plus mail-path processes (opendkim, panel, postfix). +# Does not verify TLS, DNS, or end-to-end delivery — see README Operations. +HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \ + CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1 + # The entrypoint fixes /data ownership (bind mount) as root, then execs # supervisord, which becomes PID 1 and owns process supervision (spec 4). ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/cmd/panel/envdoc_test.go b/cmd/panel/envdoc_test.go new file mode 100644 index 0000000..3bb8675 --- /dev/null +++ b/cmd/panel/envdoc_test.go @@ -0,0 +1,133 @@ +package main + +import ( + "slices" + "testing" +) + +// documentedPublic matches the README "Environment variables" table. +var documentedPublic = []string{ + "SELFPOST_HOSTNAME", + "SUBMISSION_ENABLE", + "RATE_LIMIT_MESSAGES_PER_IP", + "RATE_LIMIT_WINDOW_SECONDS", + "SEND_LOG_RETENTION_DAYS", + "PANEL_SESSION_IDLE_DAYS", + "SELFPOST_DNS_RESOLVERS", + "TRUSTED_PROXY_CIDR", +} + +// documentedInternal matches README "Internal variables (not part of the operator interface)". +var documentedInternal = []string{ + "SELFPOST_DATA_DIR", + "SELFPOST_DB_PATH", + "SELFPOST_SETUP_TOKEN_FILE", + "PANEL_HTTP_ADDR", + "JOURNAL_MILTER_SOCKET", + "MAIL_LOG", + "PANEL_COOKIE_SECURE", + "OPENDKIM_SOCKET", + "OPENDKIM_DIR", + "DKIM_SELECTOR_DEFAULT", + "SASL_DB_PATH", + "SASL_REALM", + "POSTFIX_DIR", + "POSTFIX_SENDER_LOGIN_MAPS", + "MILTER_CONNECT_TIMEOUT", + "MILTER_COMMAND_TIMEOUT", + "MILTER_CONTENT_TIMEOUT", + "MILTER_WAIT_TIMEOUT", + "TLS_RELOAD_INTERVAL_SECONDS", + "LOGROTATE_INTERVAL_SECONDS", +} + +// documentedComposeFixed are env keys documented outside the table (TLS paths fixed in compose). +var documentedComposeFixed = []string{ + "TLS_CERT_FILE", + "TLS_KEY_FILE", +} + +// loadConfigKeys is every environment variable read by loadConfig() in main.go. +// Update together with loadConfig when adding a new key. +var loadConfigKeys = []string{ + "SELFPOST_DATA_DIR", + "PANEL_HTTP_ADDR", + "JOURNAL_MILTER_SOCKET", + "MAIL_LOG", + "SEND_LOG_RETENTION_DAYS", + "SELFPOST_DB_PATH", + "SELFPOST_SETUP_TOKEN_FILE", + "SELFPOST_HOSTNAME", + "PANEL_COOKIE_SECURE", + "SUBMISSION_ENABLE", + "TRUSTED_PROXY_CIDR", + "PANEL_SESSION_IDLE_DAYS", + "SELFPOST_DNS_RESOLVERS", + "TLS_CERT_FILE", + "OPENDKIM_SOCKET", + "OPENDKIM_DIR", + "DKIM_SELECTOR_DEFAULT", + "SASL_DB_PATH", + "SASL_REALM", + "POSTFIX_DIR", +} + +// buildScriptKeys is every ${VAR:-…} / os.Getenv used in build/*.sh and entrypoint.sh +// but not necessarily in loadConfig. Update when startup scripts gain a new knob. +var buildScriptKeys = []string{ + "SELFPOST_HOSTNAME", + "TLS_CERT_FILE", + "TLS_KEY_FILE", + "RATE_LIMIT_MESSAGES_PER_IP", + "RATE_LIMIT_WINDOW_SECONDS", + "OPENDKIM_SOCKET", + "JOURNAL_MILTER_SOCKET", + "POSTFIX_SENDER_LOGIN_MAPS", + "SASL_DB_PATH", + "SUBMISSION_ENABLE", + "MILTER_CONNECT_TIMEOUT", + "MILTER_COMMAND_TIMEOUT", + "MILTER_CONTENT_TIMEOUT", + "MILTER_WAIT_TIMEOUT", + "TLS_RELOAD_INTERVAL_SECONDS", + "LOGROTATE_INTERVAL_SECONDS", +} + +func documentedKeys() []string { + keys := append([]string{}, documentedPublic...) + keys = append(keys, documentedInternal...) + keys = append(keys, documentedComposeFixed...) + slices.Sort(keys) + return slices.Compact(keys) +} + +func TestLoadConfigKeysDocumented(t *testing.T) { + doc := documentedKeys() + for _, key := range loadConfigKeys { + if !slices.Contains(doc, key) { + t.Errorf("loadConfig reads %s but it is not listed in README public, internal, or compose-fixed env docs", key) + } + } +} + +func TestBuildScriptKeysDocumented(t *testing.T) { + doc := documentedKeys() + for _, key := range buildScriptKeys { + if !slices.Contains(doc, key) { + t.Errorf("build scripts read %s but it is not listed in README env documentation", key) + } + } +} + +func TestDocumentedKeysAreRead(t *testing.T) { + read := append([]string{}, loadConfigKeys...) + read = append(read, buildScriptKeys...) + slices.Sort(read) + read = slices.Compact(read) + + for _, key := range documentedKeys() { + if !slices.Contains(read, key) { + t.Errorf("README documents %s but no code in loadConfig or build scripts reads it", key) + } + } +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..f92f353 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,142 @@ +# SelfPost — architecture (as-built) + +**Source of truth:** the code tree, not historical specs. Synchronise this file +when env keys, routes, or mail-path behaviour change. Verification method: +[documentation-plan.md](documentation-plan.md) §2. + +User install/operations: [README.md](../README.md). Product boundaries: +[product.md](product.md). + +--- + +## Image and processes + +Single Debian slim image. `entrypoint.sh` (root) fixes `/data` ownership and +milter socket directories, validates `SELFPOST_HOSTNAME`, then execs +`supervisord` as PID 1. + +Managed programs ([build/supervisord.conf](../build/supervisord.conf)): + +| Program | User | Priority | Role | +|---|---|---|---| +| `opendkim` | opendkim | 100 | DKIM signing milter | +| `panel` | panel | 200 | HTTP UI + journal-milter + log-tailer goroutine | +| `postfix` | root (wrapper) | 300 | MTA — started only after both milter sockets exist | +| `postfix-reload` | root | — | On-demand `postfix reload` (autostart off) | +| `cert-reload` | root | 400 | Daily `postfix reload` for renewed TLS certs | +| `logrotate` | root | 400 | Periodic `mail.log` rotation | + +Start order: OpenDKIM → panel (opens journal-milter socket) → Postfix wrapper +polls unix sockets (timeout `MILTER_WAIT_TIMEOUT`, default 30s) then +`postfix start-fg`. + +`crashexit` event listener exits the container on any managed process FATAL so +Docker `restart: unless-stopped` recreates a broken instance. + +**Liveness:** `GET /healthz` (unauthenticated) returns 200 when opendkim, +panel, and postfix are RUNNING; Docker `HEALTHCHECK` uses the same probe. + +--- + +## Mail path + +``` +Client ──TLS+SASL──► Postfix (465 smtps, optional 587 submission) + │ + ├─► OpenDKIM milter (sign, tempfail on failure) + ├─► journal-milter (send log + L2 rate limits, fail-open) + └─► outbound MX delivery (port 25 client) +``` + +### Postfix ([build/postfix-config.sh](../build/postfix-config.sh)) + +- **465/smtps** — implicit TLS, SASL required; primary listener. +- **587/submission** — only when `SUBMISSION_ENABLE=true`; STARTTLS with + `smtpd_tls_security_level=encrypt`. +- **No open relay** — `permit_sasl_authenticated`, `reject_unauth_destination`; + `smtpd_sender_login_maps` + `reject_sender_login_mismatch`. +- **Level-1 rate limit** — `smtpd_client_message_rate_limit` / + `anvil_rate_time_unit` from `RATE_LIMIT_*` env vars; independent of milter. +- **Chroot disabled** for all services (DNS/TLS inside container). +- **TLS certs** — read-only mount at `TLS_CERT_FILE` / `TLS_KEY_FILE`; daily + reload via `cert-reload`. + +### OpenDKIM + +Per-domain keys under `/data/opendkim/keys`; `KeyTable` / `SigningTable` maintained +by the panel. Socket `/run/opendkim/opendkim.sock`. + +### Panel binary ([cmd/panel](../cmd/panel)) + +One process, three roles: + +1. **HTTP server** — `:8080` (`PANEL_HTTP_ADDR`); HTTPS terminated by reverse + proxy only. +2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/ + Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open** + (`default_action=accept`) so milter failure does not stop mail. +3. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by + queue-id. + +Milter chain in Postfix: OpenDKIM (tempfail) then journal (accept on failure). + +--- + +## Panel HTTP surface + +Route table: [internal/web/web.go](../internal/web/web.go). Authenticated +unless noted. + +| Route | Purpose | +|---|---| +| `/healthz` | Liveness (no auth) | +| `/setup/*` | One-time admin bootstrap | +| `/login`, `/logout` | Session auth | +| `/status` | Process, cert, socket, PTR checks | +| `/domains`, `/domains/*` | Domain and application CRUD, DKIM, L2 limits | +| `/deliveries` | Send log with filters | +| `/mail-queue` | Postfix queue view | +| `/system-log` | `mail.log` tail | +| `/reload` | Reload OpenDKIM + Postfix maps | +| `/backup` | Full backup download, domain import | +| `/account` | Admin username/password | + +HTMX polling refreshes monitoring fragments; polling does not extend session +idle timeout. + +--- + +## Persistence (`/data` bind mount) + +| Path | Contents | +|---|---| +| `selfpost.db` | SQLite: domains, apps, admin, sessions, send log, L2 limits | +| `setup-token` | First-run setup token file | +| `opendkim/` | DKIM keys + tables | +| `sasl/sasldb2` | Application SASL credentials | +| `postfix/sender_login_maps` | Login → From binding | +| `manifest.json` | Backup version stamp (consumed on restore) | + +Not in `/data`: TLS certificates (reverse-proxy mount), Postfix queue +(transit mail not migrated by design). + +**Rotation:** send-log retention `SEND_LOG_RETENTION_DAYS` (default 90); +`mail.log` via logrotate (14 files, check every 6h, `postfix reload` on rotate). + +**Backup:** panel button or `selfpost-backup` CLI — SQLite snapshot + tar of +`/data` tree; version check on restore. Stopped-container `tar` of `./data` is +safe (see README). + +--- + +## Security (summary) + +Mandatory checklist: [security.md](security.md). Accepted trade-offs (CSRF +origin check, no CSRF tokens) are documented there separately. + +--- + +## Configuration + +Public and internal env vars: [README § Environment variables](../README.md#environment-variables). +Regression test: [cmd/panel/envdoc_test.go](../cmd/panel/envdoc_test.go). diff --git a/docs/specification.md b/docs/archive/specification-v1.0.md similarity index 99% rename from docs/specification.md rename to docs/archive/specification-v1.0.md index ac77da3..a9ebcae 100644 --- a/docs/specification.md +++ b/docs/archive/specification-v1.0.md @@ -1,3 +1,8 @@ +> **Исторический снимок v1.0, не источник истины.** Актуальные документы: +> [product.md](../product.md), [architecture.md](../architecture.md), +> [development.md](../development.md), [security.md](../security.md), +> [README.md](../../README.md). + # Техническое задание: SelfPost **Версия:** 1.0 diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..1c0c815 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,130 @@ +# SelfPost — development + +**What this file is.** How to build, test, and ship changes. Current sprint +state lives in [progress.md](progress.md) — read that first after `/clear`. + +Product boundaries: [product.md](product.md). As-built layout: +[architecture.md](architecture.md). + +--- + +## Repository layout + +- `cmd/panel` — panel HTTP server + milter + log-tailer +- `cmd/selfpost-backup` — CLI backup (`docker exec … selfpost-backup`) +- `internal/` — domain logic, store, web handlers, health checks +- `build/` — Dockerfile, supervisord, Postfix/OpenDKIM wiring, entrypoint +- `deploy/` — `docker-compose.yml`, proxy examples, `.env.example` +- `test/e2e/` — **separate Go module**; container integration tests + +--- + +## Local Go workflow + +Requires Go 1.26+ and `CGO_ENABLED=0` (pure Go SQLite). + +```sh +make vet # go vet ./... +make test # go test ./... +make build # bin/panel, bin/selfpost-backup (VERSION=dev by default) +make build VERSION=1.0.0 +``` + +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 +``` + +**Env documentation regression:** `go test ./cmd/panel -run TestLoadConfig` — +new `loadConfig` keys must appear in README env lists +([cmd/panel/envdoc_test.go](../cmd/panel/envdoc_test.go)). + +--- + +## End-to-end tests + +Hermetic container suite (plan C.4): + +```sh +make e2e +# equivalent: cd test/e2e && go test -v -timeout 20m ./... +``` + +Uses `deploy/docker-compose.yml` + `test/e2e/compose.override.yml` (high +ports, test hostname, fake DNS zone, smtp-sink). Requires **Docker + Compose v2** +on the machine running tests. Not included in `go test ./...` of the main module. + +CI (`release.yml`): matrix build → e2e per arch → push `ghcr.io` on tag +`vX.Y.Z`. + +--- + +## Dev server (full container) + +When unit tests are not enough — Postfix, OpenDKIM, supervisord, real SMTP: + +**Typical setup:** edit locally → sync tree to dev host → build/test there. + +Documented dev host: `selfpost.mixfed.ru` (Debian 12). Sync example from +[progress.md](progress.md): + +```sh +tar -czf - --exclude=.git . | ssh root@selfpost.mixfed.ru \ + 'rm -rf /root/selfpost-src && mkdir -p /root/selfpost-src && tar -xzf - -C /root/selfpost-src' +``` + +On the server (Go in `/usr/local/go/bin` if not in PATH): + +```sh +cd /root/selfpost-src +/usr/local/go/bin/go vet ./... +/usr/local/go/bin/go test ./... +docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev . +``` + +Manual smoke on production-like host: panel at `https://selfpost.mixfed.ru`, +real LE cert, live deliverability — not replaceable by e2e alone (no outbound +25 on CI runners, no real PTR/reputation). + +--- + +## Commit and changelog protocol + +From [progress.md](progress.md): + +1. Meaningful step → entry under `[Unreleased]` in [CHANGELOG.md](../CHANGELOG.md) + (Keep a Changelog format). +2. Commit on `main` unless asked for a branch. **Do not commit unless the user + asks.** +3. Before `/clear` at end of a phase: update `progress.md`, verify acceptance + criteria, CHANGELOG, final commit. + +Documentation changes that add/rename env vars, panel routes, or observable +mail behaviour ship in the **same commit** as the code change. + +Release tagging and image push — only on explicit request. + +--- + +## Agent rules (formerly spec §12) + +1. **No git commits** without explicit instruction in the prompt. +2. After Go changes: `go build`, `go vet`; fix all issues. Run `go test` when + tests exist. +3. Before calling a container task done: image builds and container starts. +4. Iterate: minimal skeleton first, then features. +5. Security requirements in [security.md](security.md) — implement with the feature, + not deferred. +6. Do not implement out-of-scope items ([product.md](product.md)) or change + fixed assumptions without agreement. +7. For large tasks: propose a plan before coding unless the user already approved + one. +8. Check licence compatibility of new Go dependencies (permissive or + GPL-family for AGPL-3.0 project). + +Model routing (from progress): security/infra → Opus; UI/docs → Sonnet; +trivial mechanics → Haiku. Pre-release security **review** (not authorship) → +Fable. diff --git a/docs/documentation-plan.md b/docs/documentation-plan.md index 90df893..fb1153b 100644 --- a/docs/documentation-plan.md +++ b/docs/documentation-plan.md @@ -1,22 +1,18 @@ # План документации SelfPost **Зачем этот файл.** Документация — часть поставки (deliverables v1.0, пп. 5, 7, 9 -в [specification.md](specification.md)), а не сопроводительный текст. Перед тегом +в [archive/specification-v1.0.md](archive/specification-v1.0.md)), а не сопроводительный текст. Перед тегом релиза она обязана описывать **то, что делает код**, а не то, что задумывалось: расхождение здесь — такой же дефект, как несоответствие требованиям, только обнаруживает его пользователь на своём проде. План описывает, из чего состоит пакет, как он сверяется с кодом, что уже разошлось (первый проход выполнен, результаты ниже) и что с этим делать. -**Цель после выполнения плана:** [specification.md](specification.md) (ТЗ v1.0) -**выводится из обращения** — не потому что требования исчезли, а потому что v1.0 -реализован и каждый блок ТЗ получает постоянный дом: пользовательское — в +**Цель (достигнута в D9):** ТЗ v1.0 **выведено из обращения** — v1.0 +реализован и каждый блок ТЗ получил постоянный дом: пользовательское — в README, устройство — в `architecture.md`, продуктовые границы — в `product.md`, обязательная безопасность — в `security.md`, процесс разработки — -в `development.md`. Живой `specification.md` после этого держит два риска: -дублирование с кодом и ложное ощущение «источника истины», который уже не -сверяют. Задача **D9** — миграция остатков и снятие файла; до её закрытия -ссылки на ТЗ в этом файле — исторические. +в `development.md`. Архив: [archive/specification-v1.0.md](archive/specification-v1.0.md). **Место в общем плане:** документационный проход идёт вместе с **D.5** ([implementation-plan.md](implementation-plan.md)) — до тега следующего релиза, @@ -43,9 +39,7 @@ README, устройство — в `architecture.md`, продуктовые г ### Рабочие документы проекта (не поставка, но обязаны быть верны) -[specification.md](specification.md) — ТЗ v1.0; **на вывод после D9** (см. карту -миграции ниже). До закрытия D9 — ещё используется как историческая ссылка в -находках раздела 3. +[archive/specification-v1.0.md](archive/specification-v1.0.md) — исторический снимок ТЗ v1.0 (D9 закрыт). [implementation-plan.md](implementation-plan.md) — открытые вопросы для v1.0/v1.x. [roadmap.md](roadmap.md) — линия 2.x.x. [progress.md](progress.md) — живой трекер, читается первым после `/clear`. diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 1cde02c..7adb202 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -7,7 +7,7 @@ v1.0/v1.x: открытые вопросы для согласования. Об (входящий релей, роль администратора домена) вынесен в [roadmap.md](roadmap.md). -**Основа:** [specification.md](specification.md) v1.0. +**Основа:** [product.md](product.md) v1.0. --- diff --git a/docs/product.md b/docs/product.md new file mode 100644 index 0000000..f889d43 --- /dev/null +++ b/docs/product.md @@ -0,0 +1,102 @@ +# SelfPost — product boundaries + +**What this file is.** Stable product definition for SelfPost v1.0: purpose, +deployment assumptions, explicit out-of-scope items, and the multi-domain +model. User-facing install and operations live in [README.md](../README.md); +as-built technical detail in [architecture.md](architecture.md). + +--- + +## Purpose + +SelfPost is a self-hosted **outbound SMTP relay** with a web control panel, +shipped as a single Docker image. It sends mail directly to the internet from +your own IP with per-domain DKIM signing. + +**Primary workflow:** configure the relay once (domains, DKIM, SASL +applications), then point scripts and apps at the SMTP endpoint. SelfPost +delivers as the configured sending domain. + +--- + +## Deployment context (fixed assumptions) + +These constraints are intentional; changing them requires an explicit product +decision: + +1. **VPS or home server** — one image for both. Raspberry Pi and similar boards + are not a current target but not ruled out forever. +2. **Send from your own IP (DIY)** — no intermediate relay provider. Requires + outbound port 25, static IP, and configurable PTR/rDNS from the operator. +3. **Single container** — Postfix, OpenDKIM, and the panel run under one + `supervisord` inside one image. +4. **Panel is internet-facing** — mandatory security requirements in + [security.md](security.md). + +**Infrastructure the operator provides (not SelfPost features):** unblocked +outbound TCP 25, static IP, PTR/rDNS, acceptable IP reputation. SelfPost does +not detect, bypass, or compensate for missing prerequisites; mail simply fails +to deliver when they are absent. + +--- + +## Out of scope + +Explicitly excluded to prevent scope creep: + +- Inbound mail (IMAP/POP3, mailboxes, delivery to user inboxes) +- Webmail +- Multi-user panel / organisations / roles — one administrator; managing + **multiple sending domains** is in scope (see below) +- Inbound antispam/antivirus (rspamd, ClamAV, etc.) +- A custom MTA — Postfix is used as-is +- Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only + +Future line **2.x.x** (optional inbound relay, domain-admin role) is tracked in +[roadmap.md](roadmap.md) and requires explicit approval before implementation. + +--- + +## Multi-domain model + +SelfPost is a **multi-domain outbound relay**. Two linked entities: + +### Sending domain + +Example: `example.com`. Has its own DKIM key and selector. + +### Application (account) + +A SASL login/password bound to **one** domain. A domain may have several +applications (e.g. newsletter vs alerts), each with its own credentials, but +each may send **only from its own domain**, never from another. + +### From-address mode (per application) + +Set when creating or editing an application: + +1. **Any address in the domain** — `*@example.com` (wildcard in + `smtpd_sender_login_maps`). +2. **Explicit address list** — only listed From addresses within the domain; + anything else is rejected even if it belongs to the same domain. + +In both modes the From address must belong to the application's domain. + +### What the panel manages per domain + +- DKIM key + selector (shared by all applications on that domain) +- One or more applications (SASL credentials + From mode + optional rate limits) +- DNS guidance (DKIM TXT; reminders for SPF/DMARC) + +Adding a domain does **not** create an application automatically. + +### Lifecycle + +- **Add domain** — record, DKIM key, DNS instructions; no application yet. +- **Add application** — SASL pair (password shown once), From mode, map entries, + `postfix reload`. +- **Delete domain** — removes DKIM key and **all** its applications. +- **Delete application** — removes only that app's SASL and map entries. + +This is not multi-tenancy (one admin); it is one owner operating several +sending domains with independent application credentials. diff --git a/docs/progress.md b/docs/progress.md index 249f190..9cea32a 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -3,7 +3,8 @@ Живой трекер состояния. **Переживает `/clear`** — читается первым при возобновлении работы. План (открытые вопросы для v1.0/v1.x): [implementation-plan.md](implementation-plan.md). Линия 2.x.x (входящий релей, роль администратора домена): [roadmap.md](roadmap.md). -ТЗ: [specification.md](specification.md). Принятые риски безопасности: [security.md](security.md). +Продукт: [product.md](product.md), устройство: [architecture.md](architecture.md). +Процесс разработки: [development.md](development.md). Принятые риски безопасности: [security.md](security.md). История релизов: [CHANGELOG.md](../CHANGELOG.md). История сделанного по фазам (0→13, все закрыты) — в `git log` и в CHANGELOG, здесь не дублируется. @@ -11,7 +12,7 @@ 1. Прочитать этот файл (текущее состояние, что дальше). 2. Открыть `implementation-plan.md` — там нерешённые вопросы для v1.0/v1.x; линия 2.x.x (Фаза O1+, роль администратора домена) — в `roadmap.md`; принятые риски безопасности — в `security.md`. -3. При необходимости — детали в `specification.md`. +3. При необходимости — [architecture.md](architecture.md) и [product.md](product.md). 4. Продолжить с пункта «Следующий шаг». ## Модель по типу работы @@ -43,7 +44,7 @@ - **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.mixfed.ru`, отдельный контейнер `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.mixfed.ru`, отдельный образ `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.mixfed.ru`)**: `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–D5 закрыты):** [documentation-plan.md](documentation-plan.md) — D1 Operations/Rate limiting, D2 env-справочник, D3 бэкап через остановленный контейнер + `tar` и потребление `manifest.json`, D4 точечные правки README/compose (баннер, 587, комментарии), D5 синхронизация `implementation-plan.md` (B.1 — смена пароля гасит остальные сессии, не текущую). Остаются D6–D9 (HEALTHCHECK, регресс-тест env, architecture/development, вывод specification). Часть предрелизного гейта наравне с e2e и ревизией безопасности. +- **Документация (D1–D9 закрыты):** [documentation-plan.md](documentation-plan.md) — D6 HEALTHCHECK/`/healthz`, D7 env-doc regression test, D8 `architecture.md`/`development.md`, D9 вывод `specification.md` (архив в `docs/archive/`, живые документы `product.md` + расширенный `security.md`). Часть предрелизного гейта наравне с e2e и ревизией безопасности. - **Дальше:** пункт **D.5** плана — предрелизная проверка на уязвимости моделью Fable по всему дифу от `v1.0.0` плюс повторный проход по ТЗ 7.6; вместе с e2e (C.4, готов) это гейт перед тегом релиза. - **Дальше — то, что перечислено в `implementation-plan.md`:** открытые вопросы закрыты, раздел E теперь только указатель на объём 2.x (входящий релей O1+ и роль администратора домена; 2FA снята с рассмотрения); остаются принятые риски безопасности (переехали в [security.md](security.md): `POST` без `Sec-Fetch-Site`/`Origin` пропускается, CSRF-токенов нет) и опциональная **Фаза O1+** (входящий релей, линия 2.x.x, требует согласования). - **Прод:** `selfpost.mixfed.ru`, реальный 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 6851d7d..2afc2f2 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -8,7 +8,7 @@ проекта, а не доработка по своей инициативе. Присутствие пункта здесь фиксирует намерение и дизайн; кодирование начинается отдельным решением. -**Основа:** [specification.md](specification.md) v1.0. Несделанное для v1.0/v1.x +**Основа:** [product.md](product.md) v1.0. Несделанное для v1.0/v1.x — в [implementation-plan.md](implementation-plan.md). --- diff --git a/docs/security.md b/docs/security.md index f9c2b83..f98e960 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1,19 +1,78 @@ -# Безопасность: принятые риски +# Безопасность -**Что здесь.** Обязательные требования к безопасности — [ТЗ 7.6](specification.md); -соответствие им проверено полным аудитом на v1.0 и здесь не пересказывается. -Hardening сверх обязательного 7.6 (security-заголовки, проверка origin, cookie -`__Host-` с обнаружением дублей — Фаза 14) тоже закрыт, история — в -[CHANGELOG.md](../CHANGELOG.md) и `git log`. Этот документ держит третью -категорию: то, что закрыто **сознательно не было**, чтобы решение не потерялось -и не переоткрывалось заново. +**Что здесь.** (1) **Обязательные требования** — чеклист, который v1.0 обязан +выполнять; полный аудит на v1.0 пройден. (2) **Принятые риски** — сознательные +отступления сверх обязательного, чтобы решение не потерялось. -Здесь, а не в [implementation-plan.md](implementation-plan.md), потому что план — -только про несделанную работу, а принятый риск — не работа, а решение: у него нет -состояния «в очереди», есть условие, при котором к нему возвращаются. +Hardening сверх обязательного (security-заголовки, проверка origin, cookie +`__Host-` с обнаружением дублей — Фаза 14) закрыт; история — в +[CHANGELOG.md](../CHANGELOG.md) и `git log`. + +Продуктовые границы: [product.md](product.md). Устройство as-built: +[architecture.md](architecture.md). + +--- + +## Обязательные требования + +Панель публична из интернета — пункты ниже **не опциональны**. + +### Первичная инициализация администратора + +- Одноразовая secret-ссылка `/setup/`, **не** env с готовым хэшем пароля. +- Токен ≥128 бит (`crypto/rand`); дублируется в `/data/setup-token`. +- Срок жизни токена — **10 минут**; после истечения или рестарта без завершённой + настройки — перегенерация и новый вывод в лог. +- Rate limiting на `/setup/` по IP, отдельно от логина. +- Сравнение токена — **константное по времени** (`subtle.ConstantTimeCompare`). +- Неудачные попытки **не** инвалидируют токен досрочно (защита от DoS настройки). +- После создания администратора — токен навсегда недействителен, `/setup/*` → 404. +- Пароль администратора — только bcrypt (или argon2) в SQLite; без plaintext/MD5. +- `PANEL_USERNAME` / `PANEL_PASSWORD_HASH` в env **не используются**. + +### SASL-пароли приложений + +- Панель **генерирует** пароль при создании/перевыпуске, показывает **один раз**. +- В `sasldb2` — в форме, требуемой SASL (не plaintext в панели); утерян — только + перевыпуск. + +### Ввод и конфигурация + +- Серверная валидация email/доменов (whitelist символов); клиентская не считается + защитой. +- Режим «список адресов» — каждый адрес принадлежит домену приложения до записи. +- `postfix reload` и любой `exec` — **без** shell-интерполяции пользовательского + ввода; аргументы отдельными элементами. +- Запись в конфиг-файлы — с экранированием (нет инъекции директив Postfix). + +### Аутентификация и сессии + +- Rate limiting на логин (по IP, с блокировкой/задержкой). +- Сессии: криптографически случайный токен; cookie `HttpOnly`, `Secure`, `SameSite`. +- Сессии в SQLite (SHA-256 токена, не сам токен); скользящий idle + (`PANEL_SESSION_IDLE_DAYS`). + +### Вывод и процесс + +- Рендер через `html/template` с автоэкранированием (очередь, лог, журнал, темы). +- Процесс панели **не root** (`user=panel` в supervisord); доступ к путям через + группу `selfpost` и минимальные права. + +### Почтовый тракт (связанное с безопасностью) + +- **Не open relay** — только SASL; `reject_unauth_destination`; + `smtpd_sender_login_maps` + `reject_sender_login_mismatch`. +- TLS обязателен до передачи кредов (465 wrapper / 587 `encrypt`). +- `TRUSTED_PROXY_CIDR` — только явно доверенные прокси для `X-Forwarded-For` + при rate-limit логина; пусто = XFF игнорируется. + +--- ## Принятые риски +Здесь, а не в [implementation-plan.md](implementation-plan.md): план — про +несделанную работу, принятый риск — решение с условием возврата. + - **`POST` без `Sec-Fetch-Site` и без `Origin` пропускается.** Клиент, не посылающий ни одного из двух — по-настоящему старый браузер или webview с замороженным движком, — остаётся уязвим к CSRF с любого сайта. @@ -28,7 +87,7 @@ Hardening сверх обязательного 7.6 (security-заголовки вопросу считать появление требования «устойчиво независимо от браузера». От XSS внутри самой панели не спас бы и токен: код, исполняющийся в origin панели, отправит запрос сам — против этого работают автоэкранирование - `html/template` (7.6.7) и CSP, поэтому шаблоны не должны содержать + `html/template` и CSP, поэтому шаблоны не должны содержать inline-скриптов и inline-стилей. ## Как этот список пополняется diff --git a/internal/health/health_test.go b/internal/health/health_test.go index 37994ee..9d9b902 100644 --- a/internal/health/health_test.go +++ b/internal/health/health_test.go @@ -163,3 +163,28 @@ func writeCert(t *testing.T, path, cn string, validFor time.Duration) { t.Fatal(err) } } + +func TestLivenessFromParsedProcesses(t *testing.T) { + allRunning := `opendkim RUNNING pid 21, uptime 0:04:10 +panel RUNNING pid 22, uptime 0:04:09 +postfix RUNNING pid 23, uptime 0:04:08 +postfix-reload STOPPED Not started +` + procs := parseProcesses(allRunning) + for _, p := range procs { + if mailPathPrograms[p.Name] && p.Status != StatusOK { + t.Fatalf("%s should be ok for liveness, got %q", p.Name, p.Status) + } + } + + postfixDead := `opendkim RUNNING pid 21, uptime 0:04:10 +panel RUNNING pid 22, uptime 0:04:09 +postfix FATAL Exited too quickly +` + procs = parseProcesses(postfixDead) + for _, p := range procs { + if p.Name == "postfix" && p.Status == StatusOK { + t.Fatal("postfix FATAL should not grade as OK") + } + } +} diff --git a/internal/health/liveness.go b/internal/health/liveness.go new file mode 100644 index 0000000..bbb1b6d --- /dev/null +++ b/internal/health/liveness.go @@ -0,0 +1,46 @@ +package health + +import ( + "fmt" + "strings" +) + +// mailPathPrograms are the supervised processes whose absence means the +// container should not report healthy to orchestrators. +var mailPathPrograms = map[string]bool{ + "opendkim": true, + "panel": true, + "postfix": true, +} + +// Liveness reports whether the mail path is healthy enough for container +// probes. It requires opendkim, panel, and postfix to be RUNNING under +// supervisord. +func Liveness() error { + procs, err := Processes() + if err != nil { + return err + } + + seen := make(map[string]Status, len(mailPathPrograms)) + for _, p := range procs { + if mailPathPrograms[p.Name] { + seen[p.Name] = p.Status + } + } + + var unhealthy []string + for name := range mailPathPrograms { + switch seen[name] { + case StatusOK: + case StatusUnknown: + unhealthy = append(unhealthy, name+": missing") + default: + unhealthy = append(unhealthy, fmt.Sprintf("%s: %s", name, seen[name])) + } + } + if len(unhealthy) > 0 { + return fmt.Errorf("unhealthy: %s", strings.Join(unhealthy, ", ")) + } + return nil +} diff --git a/internal/web/web.go b/internal/web/web.go index ae0ae63..18c2bdd 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -14,6 +14,7 @@ import ( "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" ) @@ -207,6 +208,10 @@ func redirectToStatus(w http.ResponseWriter, r *http.Request) { func handleHealth(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") + if err := health.Liveness(); err != nil { + http.Error(w, "unhealthy\n", http.StatusServiceUnavailable) + return + } w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok\n")) }