diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b64c5c9..36f0c7c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,7 @@ name: release # can never drift apart (the invariant restore's version check in spec 7.5.A # depends on). # -# Native per-architecture builds (plan implementation-plan.md C.4), not qemu: +# Native per-architecture builds (see docs/development.md), not qemu: # running the full Postfix/OpenDKIM stack under emulation for the e2e gate # below is impractically slow. Each arch builds, e2e-gates and pushes its own # tag on its own native runner; a merge job then combines them into the one @@ -68,7 +68,7 @@ jobs: go-version: "1.26" cache-dependency-path: test/e2e/go.sum - - name: e2e (gates publishing — see docs/implementation-plan.md C.4) + - name: e2e (gates publishing — see docs/development.md) run: cd test/e2e && go test -v -timeout 20m ./... - name: Log in to ghcr.io diff --git a/CHANGELOG.md b/CHANGELOG.md index 18f9b51..e9cfc56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ## [Unreleased] +## [1.0.0] - 2026-08-09 + ### Added - A **Delivery log** on each delivery's page (`/deliveries/{id}`): the @@ -30,6 +32,28 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ### Fixed +- Postfix config: copy TLS cert/key from the (often `:ro`, host-owned) mount + into `/etc/postfix/tls-internal` as `root:root` before `postconf` / + `postfix check`. Bind-mounted keys owned by the CI/host UID made + `postfix check` fail and the container exit before supervisord started. +- Postfix config: allow `maillog_file` under `/data` via + `maillog_file_prefixes=/var,/dev/stdout,/data`, and pin the `postlog` + master.cf service. After mail.log moved to `/data/log`, `postfix check` + fatally rejected the path (default prefixes are only `/var` and + `/dev/stdout`) and often left stderr/mail.log empty. +- E2e: read `/data/setup-token` via `docker compose exec` (file is `0600` + panel-owned; host `ReadFile` got permission denied on CI). Reclaim `/data` + ownership before TempDir/stage cleanup so panel/postfix UIDs do not fail + Go's `RemoveAll`. +- Entrypoint: check `SELFPOST_HOSTNAME` before `/data` setup (so a bad identity + fails with the FATAL text, not an earlier `set -e` abort), and `chmod 755 + /data` after chown so OpenDKIM/Postfix can traverse bind mounts that arrive + as mode `0700` (Go `TempDir`, some host umasks) — otherwise KeyTable is + unreachable and the container crash-loops. E2e stand uses `restart: "no"` and + surfaces selfpost logs from the supervisor readiness check. +- E2e gate: wait for host-published `/healthz` before panel setup, and stop + ordered `TestE2E` subtests after a failure so a nil panel client cannot panic + and mask the real error (release CI on both amd64 and arm64). - A send-log row could stay `queued` forever after the container was recreated. `mail.log` moved from the ephemeral `/var/log` into the data volume (`/data/log/mail.log`, `./data/log/` on the host), so the delivery lines that @@ -61,10 +85,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version layering and route table match `web`→`store` and `POST /domains/import`; OpenDKIM drops to `opendkim` via `UserID`; guide drops the archived "spec 7.5" pointer, clarifies `POSTFIX_SENDER_LOGIN_MAPS` vs panel writes, - and states logrotate keeps 14 daily files. Intermediate CHANGELOG cuts vs - the still-pinned compose `0.1.0` image are called out in the guide and - `development.md`. Roadmap / implementation-plan point at CHANGELOG - `[0.5.0]` Security and refreshed `internal/web` size / symbol links. + and states logrotate keeps 14 daily files. Intermediate CHANGELOG cuts from + before the published `1.0.0` image are called out in the guide and + `development.md`. Roadmap points at CHANGELOG `[0.5.0]` Security and + refreshed `internal/web` size / symbol links. - Full backups no longer carry `/data/log`. It is Postfix's raw log plus its fourteen rotated copies — diagnostic output rather than state to restore, and otherwise by far the largest thing in the archive. @@ -94,6 +118,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version dev-host-specific workflow and `example.com` references removed from docs. - `docs/development.md` and `.cursor/rules/agent-rules.mdc` translated to English; `roadmap.md` remains Russian (internal tracker). +- Deploy pin and local-trial image tag set to `ghcr.io/mixeme/selfpost:1.0.0` + (compose and git tag `v1.0.0` cut together). Retired + `docs/implementation-plan.md` and `docs/v1.x-closure-plan.md`; Makefile, + `release.yml`, and e2e comments point at `docs/development.md`. Roadmap + v1.x documentation/deploy tail closed. ## [0.6.0] - 2026-08-08 diff --git a/Makefile b/Makefile index 491605c..f8ab028 100644 --- a/Makefile +++ b/Makefile @@ -30,7 +30,7 @@ test: clean: rm -rf bin -# Hermetic container e2e (plan implementation-plan.md C.4): separate Go module +# Hermetic container e2e (see docs/development.md): separate Go module # under test/e2e so its test-only dependencies (DKIM verification) never enter # this module's build graph. Builds the image fresh from this checkout, brings # up deploy/docker-compose.yml plus a test-only override on high ports and an diff --git a/README.md b/README.md index b7e1639..65e36e7 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ docker run --rm -d --name selfpost-try \ -e SELFPOST_HOSTNAME=mail.local.test \ -e PANEL_COOKIE_SECURE=false \ -v selfpost-try-data:/data \ - ghcr.io/mixeme/selfpost:0.1.0 + ghcr.io/mixeme/selfpost:1.0.0 ``` **Get the setup URL** (pick one): diff --git a/build/entrypoint.sh b/build/entrypoint.sh index b0087a5..c24179f 100644 --- a/build/entrypoint.sh +++ b/build/entrypoint.sh @@ -1,14 +1,69 @@ #!/bin/sh # Container entrypoint (runs as root, PID 1 until it execs supervisord). # +# SELFPOST_HOSTNAME is checked first (plan B.3): it must fail fast with a clear +# message before /data normalisation or Postfix config, so a bad identity never +# looks like a permissions or packaging problem (and so the e2e hostname-gate +# tests see the FATAL text rather than an earlier set -e abort). +set -e + +# SELFPOST_HOSTNAME is an identity, not a setting with a safe default: it must +# simultaneously match the PTR/rDNS record, the certificate CN/SAN, and the +# Cyrus SASL realm (spec 5.2 p.3, 8). The panel (main.go saslRealm()) and +# postfix-config.sh each fall back independently when it's unset — to +# `localhost` and to the container hostname respectively — so accounts get +# written under one realm and looked up under another and authentication +# silently fails for every application, while HELO also stops matching the +# PTR record and mail that does go out lands in spam. No fallback can be +# correct, so fail loudly here, before either side of that split has a chance +# to run, rather than leave a green panel with broken mail. +if [ -z "$SELFPOST_HOSTNAME" ]; then + cat >&2 <<'EOF' +FATAL: SELFPOST_HOSTNAME is not set. + +This is the mail server's identity: it becomes the Postfix HELO/EHLO name, +the Cyrus SASL realm that application passwords are looked up under, and it +must match the TLS certificate's CN/SAN as well as this server's PTR (reverse +DNS) record. There is no safe default — guessing any one of these wrong +breaks authentication for every application or sends outgoing mail to spam, +silently. + +Set it to the mail server's fully-qualified domain name, e.g.: + + SELFPOST_HOSTNAME=mail.example.com + +in the .env file next to your docker-compose.yml (see deploy/.env.example). +EOF + exit 1 +fi + +case "$SELFPOST_HOSTNAME" in + *[\ \ ]* | *://* | *:* ) + echo "FATAL: SELFPOST_HOSTNAME must be a bare hostname (no scheme, port, or spaces): \"$SELFPOST_HOSTNAME\"" >&2 + echo 'Example: SELFPOST_HOSTNAME=mail.example.com' >&2 + exit 1 + ;; + *.*) + ;; + *) + echo "FATAL: SELFPOST_HOSTNAME must be a fully-qualified domain name (at least one dot): \"$SELFPOST_HOSTNAME\"" >&2 + echo 'Example: SELFPOST_HOSTNAME=mail.example.com' >&2 + exit 1 + ;; +esac + # The persistent root /data is a host bind mount (spec 9), so it arrives owned # by the host user (typically root), not by the unprivileged panel user that # actually writes the SQLite database, setup token and DKIM keys (spec 7.6.8). # Fix its ownership here — the one place still running as root — before handing # off to supervisord, which starts the panel as the panel user. -set -e - +# +# Mode must stay world-traversable (0755): OpenDKIM and Postfix reach their +# trees under /data as other users. Go's testing.TempDir is 0700, and a bare +# chown would leave that mode in place — opendkim then cannot read KeyTable and +# the container crash-loops (e2e TestHostnameGate/valid_hostname_starts). chown panel:panel /data +chmod 755 /data # Restored backups or previously-created state may contain panel-owned files # under /data; make sure they stay writable without disturbing anything that a # later phase deliberately hands to another service. /data/log is exempt: it is @@ -83,51 +138,6 @@ chown opendkim:selfpost /run/opendkim chown panel:selfpost /run/selfpost chmod 2750 /run/opendkim /run/selfpost -# SELFPOST_HOSTNAME is an identity, not a setting with a safe default: it must -# simultaneously match the PTR/rDNS record, the certificate CN/SAN, and the -# Cyrus SASL realm (spec 5.2 p.3, 8). The panel (main.go saslRealm()) and -# postfix-config.sh each fall back independently when it's unset — to -# `localhost` and to the container hostname respectively — so accounts get -# written under one realm and looked up under another and authentication -# silently fails for every application, while HELO also stops matching the -# PTR record and mail that does go out lands in spam. No fallback can be -# correct, so fail loudly here, before either side of that split has a chance -# to run, rather than leave a green panel with broken mail. -if [ -z "$SELFPOST_HOSTNAME" ]; then - cat >&2 <<'EOF' -FATAL: SELFPOST_HOSTNAME is not set. - -This is the mail server's identity: it becomes the Postfix HELO/EHLO name, -the Cyrus SASL realm that application passwords are looked up under, and it -must match the TLS certificate's CN/SAN as well as this server's PTR (reverse -DNS) record. There is no safe default — guessing any one of these wrong -breaks authentication for every application or sends outgoing mail to spam, -silently. - -Set it to the mail server's fully-qualified domain name, e.g.: - - SELFPOST_HOSTNAME=mail.example.com - -in the .env file next to your docker-compose.yml (see deploy/.env.example). -EOF - exit 1 -fi - -case "$SELFPOST_HOSTNAME" in - *[\ \ ]* | *://* | *:* ) - echo "FATAL: SELFPOST_HOSTNAME must be a bare hostname (no scheme, port, or spaces): \"$SELFPOST_HOSTNAME\"" >&2 - echo 'Example: SELFPOST_HOSTNAME=mail.example.com' >&2 - exit 1 - ;; - *.*) - ;; - *) - echo "FATAL: SELFPOST_HOSTNAME must be a fully-qualified domain name (at least one dot): \"$SELFPOST_HOSTNAME\"" >&2 - echo 'Example: SELFPOST_HOSTNAME=mail.example.com' >&2 - exit 1 - ;; -esac - # Generate the outbound-relay Postfix configuration from the environment (spec # 5). Kept out of the image build so cert paths, rate limits, hostname and the # optional 587 service are all driven by env at run time, and re-derived on every diff --git a/build/postfix-config.sh b/build/postfix-config.sh index 3bd634e..a595c96 100644 --- a/build/postfix-config.sh +++ b/build/postfix-config.sh @@ -23,8 +23,26 @@ HOSTNAME_VALUE="${SELFPOST_HOSTNAME:-$(hostname -f 2>/dev/null || hostname)}" # TLS material supplied by the reverse-proxy through a read-only bind mount # (spec 5.2). The relay requires TLS on 465; if these files are absent the # master still starts but TLS handshakes on 465 fail until they appear. -TLS_CERT="${TLS_CERT_FILE:-/etc/postfix/tls/fullchain.pem}" -TLS_KEY="${TLS_KEY_FILE:-/etc/postfix/tls/privkey.pem}" +# +# Postfix insists the private key is root-owned and mode 0600. The bind mount +# is often :ro and owned by the host user (e2e TempDir / CI runner UID), so +# copy into a writable internal dir and normalise ownership before postconf +# and `postfix check`. +TLS_CERT_SRC="${TLS_CERT_FILE:-/etc/postfix/tls/fullchain.pem}" +TLS_KEY_SRC="${TLS_KEY_FILE:-/etc/postfix/tls/privkey.pem}" +TLS_INTERNAL_DIR=/etc/postfix/tls-internal +TLS_CERT="$TLS_CERT_SRC" +TLS_KEY="$TLS_KEY_SRC" +if [ -f "$TLS_CERT_SRC" ] && [ -f "$TLS_KEY_SRC" ]; then + mkdir -p "$TLS_INTERNAL_DIR" + cp -f "$TLS_CERT_SRC" "$TLS_INTERNAL_DIR/fullchain.pem" + cp -f "$TLS_KEY_SRC" "$TLS_INTERNAL_DIR/privkey.pem" + chown root:root "$TLS_INTERNAL_DIR/fullchain.pem" "$TLS_INTERNAL_DIR/privkey.pem" + chmod 0644 "$TLS_INTERNAL_DIR/fullchain.pem" + chmod 0600 "$TLS_INTERNAL_DIR/privkey.pem" + TLS_CERT="$TLS_INTERNAL_DIR/fullchain.pem" + TLS_KEY="$TLS_INTERNAL_DIR/privkey.pem" +fi # Level-1 rate limit (native Postfix anvil, spec 5 p.5 / 7.4). Conservative # defaults, sensible during IP warm-up (spec 10). @@ -54,14 +72,23 @@ SUBMISSION_ENABLE="${SUBMISSION_ENABLE:-false}" MAIL_LOG_PATH="${MAIL_LOG:-/data/log/mail.log}" # --- main.cf ----------------------------------------------------------------- +# maillog_file lives under the persistent /data bind mount (architecture.md § +# Log tailer). Postfix's default maillog_file_prefixes are only /var and +# /dev/stdout — without /data, `postfix check` rejects the path. postconf -e \ "myhostname=${HOSTNAME_VALUE}" \ "maillog_file=${MAIL_LOG_PATH}" \ + "maillog_file_prefixes=/var,/dev/stdout,/data" \ "mydestination=" \ "relayhost=" \ "inet_interfaces=all" \ "inet_protocols=all" +# postlogd is mandatory whenever maillog_file is set (MAILLOG_README). Debian's +# stock master.cf usually has it; pin it explicitly so a stripped/upgraded +# image cannot lose the service. +postconf -M "postlog/unix-dgram=postlog unix-dgram n - n - 1 postlogd" + # This is an outbound relay: no local delivery, no per-user aliases. Empty # these so a misfiled recipient never gets delivered locally. postconf -e \ @@ -189,4 +216,12 @@ EOF # Validate the generated configuration; fail loudly if postconf produced # anything Postfix rejects, before the wrapper tries to start it. -postfix check +set +e +check_out=$(postfix check 2>&1) +ec=$? +set -e +if [ "$ec" -ne 0 ]; then + echo "postfix-config: postfix check failed exit $ec" >&2 + printf '%s\n' "$check_out" >&2 + exit "$ec" +fi diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index dca15a1..813b3f3 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -22,7 +22,7 @@ services: selfpost: - image: ghcr.io/mixeme/selfpost:0.1.0 + image: ghcr.io/mixeme/selfpost:1.0.0 restart: unless-stopped environment: SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}" diff --git a/docs/development.md b/docs/development.md index 42b2b83..d98a8f9 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,8 +1,7 @@ # SelfPost — development **What this file is.** How to build, test, document, and ship changes. Open -work for v1.x and 2.x lives in [roadmap.md](roadmap.md) (and, until the tag, -[v1.x-closure-plan.md](v1.x-closure-plan.md)). Product boundaries: +work for 2.x lives in [roadmap.md](roadmap.md). Product boundaries: [product.md](product.md). As-built layout: [architecture.md](architecture.md). --- @@ -12,10 +11,8 @@ work for v1.x and 2.x lives in [roadmap.md](roadmap.md) (and, until the tag, After `/clear` or a fresh chat: 1. Read this file (process, docs rules, model routing). -2. Open [roadmap.md](roadmap.md) for open work; until `v1.0.0`, also - [v1.x-closure-plan.md](v1.x-closure-plan.md) for the remaining closure - checklist. Accepted risks — [security.md](security.md); as-built — - [architecture.md](architecture.md). +2. Open [roadmap.md](roadmap.md) for open work. Accepted risks — + [security.md](security.md); as-built — [architecture.md](architecture.md). 3. Skim [product.md](product.md) if scope is in doubt. 4. Continue from the next unchecked step in the active plan. @@ -31,7 +28,7 @@ not duplicated here. | Security, infra, file permissions, Postfix/`postqueue`, open-relay risk | **Opus** | `mail.log` under `/data`, entrypoint permissions, queue reconcile | | UI / JS / CSS, templates, documentation (English), README | **Sonnet** | adaptive polling, this file's Documentation section | | Trivial mechanics: retarget links, grep, compose bump, CHANGELOG cut | **Haiku** | Makefile / release.yml comment fixes, deleting closed plan files | -| Security **review** (not authorship) | **Fable** | pre-release checklist pass ([implementation-plan.md](implementation-plan.md) § D — done) | +| Security **review** (not authorship) | **Fable** | pre-release checklist (CHANGELOG `[0.5.0]` Security / [security.md](security.md) — done) | Default rule: risk-critical → Opus; UI / docs / boilerplate → Sonnet; trivial mechanics → Haiku. Reviewers must not be the author of the code under review. @@ -154,20 +151,16 @@ The release image is published **only on tag** `vX.Y.Z` (not on every push to **Steps (on explicit request):** -1. Close `[Unreleased]` in [CHANGELOG.md](../CHANGELOG.md). -2. Create and push git tag `vX.Y.Z`. +1. Close `[Unreleased]` in [CHANGELOG.md](../CHANGELOG.md) and bump the pinned + tag in [deploy/docker-compose.yml](../deploy/docker-compose.yml) (and any + local-trial image references) in the **same** release commit. +2. Create and push git tag `vX.Y.Z` on that commit. 3. Workflow [release.yml](../.github/workflows/release.yml) builds, e2e-gates, and publishes `ghcr.io/mixeme/selfpost:X.Y.Z`. -4. Update the pinned tag in - [deploy/docker-compose.yml](../deploy/docker-compose.yml) in the **same** - commit as the tag (see [roadmap.md](roadmap.md) § «v1.x — documentation and - deploy tail»). -Ordinary commits **do not** publish an image. Intermediate CHANGELOG version -cuts (`0.2.0`…`0.6.0`) document history on `main`; the compose pin and the only -git release tag may lag until an explicit image publish (today: compose -`0.1.0`, tag `v0.0.1` — see [roadmap.md](roadmap.md) § «v1.x — documentation -and deploy tail»). +Ordinary commits **do not** publish an image. The compose pin and the git tag +must match (`1.0.0` / `v1.0.0` for the first published release). Intermediate +CHANGELOG sections (`0.2.0`…`0.6.0`) record development history before that cut. --- @@ -280,13 +273,9 @@ There is no `docs/archive/` directory. | As-built design | [architecture.md](architecture.md) | | Development process (this file) | [development.md](development.md) | | Security requirements and accepted risks | [security.md](security.md) | -| Internal roadmap (v1.x tail, 2.x) | [roadmap.md](roadmap.md) | +| Internal roadmap (2.x) | [roadmap.md](roadmap.md) | | Release history | [CHANGELOG.md](../CHANGELOG.md) | -`implementation-plan.md` remains only until the release cut (describes the -closed release gate); delete it in the release commit. Temporary -[v1.x-closure-plan.md](v1.x-closure-plan.md) goes away with that cut too. - ### User-facing deliverables | Artefact | Role | diff --git a/docs/guide.md b/docs/guide.md index d06b043..e95a720 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -375,12 +375,11 @@ but it can look like an open port in external scans. ## Fixed image tag `deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`), -deliberately never `:latest`. Until the `v1.0.0` cut the shipped pin is still -`0.1.0` — intermediate CHANGELOG sections (`0.2.0`…`0.6.0`) record development -cuts and do not imply a published image of that tag. Pinning matters because of -the backup version check above: the panel binary's embedded version and the -image tag that produced it are the same value by construction (the release CI -stamps both from one git tag — see `.github/workflows/release.yml`), so the -pin is what makes "restore into the same version" a checkable fact rather than -a guess. Upgrade by bumping the tag deliberately, not by riding a moving -target. +deliberately never `:latest`. The current pin is `1.0.0`. Intermediate +CHANGELOG sections (`0.2.0`…`0.6.0`) record development cuts from before that +image was published. Pinning matters because of the backup version check above: +the panel binary's embedded version and the image tag that produced it are the +same value by construction (the release CI stamps both from one git tag — see +`.github/workflows/release.yml`), so the pin is what makes "restore into the +same version" a checkable fact rather than a guess. Upgrade by bumping the tag +deliberately, not by riding a moving target. diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md deleted file mode 100644 index 6a58b58..0000000 --- a/docs/implementation-plan.md +++ /dev/null @@ -1,32 +0,0 @@ -# План реализации: SelfPost - -**Статус:** линия v1.0/v1.x до тега релиза **закрыта** — предрелизная ревизия -безопасности (§ D) выполнена 2026-08-06, релизный гейт (e2e + ревизия) открыт. -B.1–B.3 и C.4 закрыты — as-built в [architecture.md](architecture.md), -e2e/CI в [development.md](development.md), принятые риски в -[security.md](security.md). Текущее состояние и следующий шаг: -[roadmap.md](roadmap.md) и [v1.x-closure-plan.md](v1.x-closure-plan.md). -Объём 2.x.x — [roadmap.md](roadmap.md). - -**Основа:** [product.md](product.md) v1.0. - ---- - -## D. Предрелизная ревизия безопасности — ВЫПОЛНЕНО (2026-08-06) - -**Проверка моделью Fable** по дифу от аудита v1.0 (Фаза 11, `bd64e80` — тега -`v1.0.0` в репозитории нет, это его фактический эквивалент) до HEAD, плюс -полный повторный проход по чек-листу безопасности (бывшее ТЗ 7.6, теперь -[security.md](security.md)). Приоритеты из плана покрыты: аутентификация и -сессии, валидация ввода, запись в конфиги/map-файлы, `os/exec`, права в -`/data`, секреты. - -**Результат.** Эксплуатируемых уязвимостей (high/medium) не найдено. Одна -находка defence-in-depth закрыта правкой до тега: логин приложения передаётся -в `saslpasswd2` после `--`, чтобы значение, начинающееся с `-` (допустимо -whitelist'ом), не могло быть разобрано getopt как флаг -([internal/app/sasl.go](../internal/app/sasl.go)). Принятые риски в -[security.md](security.md) не пополнились — существующие записи (origin-check -fallback, отсутствие CSRF-токенов, ложно-отрицательный `bounced` при сверке -с `postqueue`) покрывают всё найденное. -Сводка ревизии — в записи `Security` CHANGELOG `[0.5.0]`. diff --git a/docs/roadmap.md b/docs/roadmap.md index 746debd..950fc52 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -11,84 +11,25 @@ решением. **Основа:** [product.md](product.md) v1.0. Процесс и правила документации — -[development.md](development.md). Несделанное для v1.0/v1.x до тега — в -[implementation-plan.md](implementation-plan.md) и -[v1.x-closure-plan.md](v1.x-closure-plan.md). Хвост закрытого -документационного прохода (D1–D9) — в секции ниже. +[development.md](development.md). История закрытых фаз v1.x — в `git log` и +[CHANGELOG.md](../CHANGELOG.md). --- ## v1.x — хвост документации и деплоя -**Статус:** не блокирует релизный тег; бывший хвост закрытого -документационного прохода (D1–D9). Делать по желанию или в релизном коммите, -где указано. Сводка чек-листов до тега — -[v1.x-closure-plan.md](v1.x-closure-plan.md). +**Статус: закрыто** в релизе `1.0.0` / git-тег `v1.0.0` +(`ghcr.io/mixeme/selfpost:1.0.0`). План закрытия и `implementation-plan.md` +удалены — история в git и CHANGELOG; `docs/archive/` не храним. -**Тег образа в compose + git tag — один релизный коммит (R1).** В -[deploy/docker-compose.yml](../deploy/docker-compose.yml) поле `image:` бампить -до версии релиза **в том же коммите**, что и git-тег `vX.Y.Z` — не раньше. -Сейчас там `0.1.0`, то есть отстаёт от целевой версии; несовпадение мешает -только до первого выката по тегу. Сам тег — последний шаг релизного гейта: -содержательная часть (e2e C.4, ревизия § D) закрыта, режется по явной команде -оператора ([development.md](development.md) § Commits and release build). После -тега `release.yml` собирает и публикует `ghcr.io/mixeme/selfpost:X.Y.Z`, -поэтому compose с новым тегом и сам тег обязаны появиться вместе — иначе -compose неделю ссылается на несуществующий образ. +| Тема | Итог | +|---|---| +| Адаптивный опрос мониторинга | 5 с / 30 с / 0 (скрытая вкладка) в `panel.js` | +| `mail.log` + reconcile | `/data/log/mail.log`; сверка с `postqueue -p` | +| Docs consolidation | процесс в [development.md](development.md); README Documentation | +| Compose pin + git tag | `1.0.0` / `v1.0.0` в одном релизном коммите | -**Убрать `implementation-plan.md` — в релизном коммите.** Документ закрыт: -уникального содержания в нём нет, § D (предрелизная ревизия безопасности) -продублирован в [security.md](security.md) и CHANGELOG `[0.5.0]/Security`, -а разделы B.1–B.3 и C.4 вырезаны ещё в `22f86d1`. Держится до тега только -потому, что описывает релизный гейт, пока тот формально не закрыт. При резке -версии: - -1. Удалить файл (история § D — в git и CHANGELOG; `docs/archive/` не храним). -2. Перецелить ссылки из кода и CI ([Makefile](../Makefile), - [.github/workflows/release.yml](../.github/workflows/release.yml), - [test/e2e/main_test.go](../test/e2e/main_test.go)) — они ссылаются на «план - C.4», секцию, которой в файле уже нет; актуальное описание e2e — в - [development.md](development.md). -3. Перецелить оставшиеся ссылки из документации на - [development.md](development.md) / [security.md](security.md) / - [roadmap.md](roadmap.md). -4. Удалить [v1.x-closure-plan.md](v1.x-closure-plan.md) в том же или следующем - коммите. - -**Готово, когда:** тег образа в compose совпадает с релизом и рядом стоит -git-тег `vX.Y.Z`; `implementation-plan.md` и `v1.x-closure-plan.md` удалены, -ссылок на них в активных документах и в коде/CI не осталось. - -(Закрыто и действия не требует: `docs/logo` как каталога нет — критерию «либо -содержит файлы, либо отсутствует» удовлетворяет; Quick start в -[README.md](../README.md) тянет `docker-compose.yml` и `.env.example` с -`raw.githubusercontent.com` — это и есть единственная площадка проекта, зеркал -больше нет.) - -**Сводный индекс документации в README.** ~~Ссылки на `docs/` разбросаны по -тексту README…~~ **Закрыто (v1.x-closure Фаза 3):** секция Documentation в -[README.md](../README.md) — единый список operator docs + roadmap. - -**Опрос мониторинга у открытой, но незанятой вкладки.** ~~Скрытая вкладка уже не -опрашивает сервер (фильтр на `htmx:beforeRequest` в -[panel.js](../internal/web/static/panel.js)). Остаток: вкладка на переднем -плане, с которой не работают, всё равно ходит раз в 5 с. Кандидат — адаптивный -интервал (5 с при активности, 30 с при простое) по `htmx:afterRequest` без -изменения `hx-trigger`. Ценность низкая: нагрузка — один SQL-запрос и рендер -фрагмента, так что это скорее гигиена, чем экономия. Допустимый исход — -осознанно не делать.~~ **Закрыто (v1.x-closure Фаза 1):** адаптивный интервал -5 с / 30 с / 0 (скрытая вкладка) в `panel.js` через `data-poll`. - -**Send-log vs `mail.log`.** ~~Persist позиции чтения сделан (таблица -`logtail_state`, миграция `0003`): после рестарта панели log-tailer дочитывает -пропущенный хвост. Остаётся пересоздание контейнера — `mail.log` не в `/data` и -теряется вместе с ним, такие строки навсегда останутся `queued`. Кандидаты, если -станет больно: volume для лога, сверка зависших строк через `postqueue`.~~ -**Закрыто (v1.x-closure Фаза 2):** сделаны оба кандидата — `mail.log` переехал в -`/data/log/`, а строки, чьи delivery-строки потеряны безвозвратно, закрываются -сверкой с `postqueue -p` (grace 2 мин → `bounced`). As-built и оставшийся риск -(ложный `bounced`): [architecture.md](architecture.md) § Log tailer, -[security.md](security.md). +Открытая работа дальше — только секции 2.x ниже. --- diff --git a/docs/security.md b/docs/security.md index e5e964a..dba4d94 100644 --- a/docs/security.md +++ b/docs/security.md @@ -94,8 +94,8 @@ Hardening сверх обязательного (security-заголовки, п ## Принятые риски -Здесь, а не в [implementation-plan.md](implementation-plan.md): план — про -несделанную работу, принятый риск — решение с условием возврата. +Принятый риск — решение с условием возврата, а не отложенная задача из +дорожной карты. - **`POST` без `Sec-Fetch-Site` и без `Origin` пропускается.** Клиент, не посылающий ни одного из двух — по-настоящему старый браузер или @@ -171,7 +171,7 @@ Origin-проверка закрывает это без изменения ни ## Как этот список пополняется -Предрелизная проверка на уязвимости ([implementation-plan.md](implementation-plan.md) -§ D, модель Fable) закрывает каждую находку одним из двух способов: правка до +Предрелизная проверка на уязвимости (модель Fable; история — CHANGELOG +`[0.5.0]` Security) закрывает каждую находку одним из двух способов: правка до тега — либо запись сюда, с обоснованием и условием возврата, как у пунктов выше. Третьего варианта («посмотрели и ладно») нет. diff --git a/docs/v1.x-closure-plan.md b/docs/v1.x-closure-plan.md deleted file mode 100644 index 45a3958..0000000 --- a/docs/v1.x-closure-plan.md +++ /dev/null @@ -1,209 +0,0 @@ -# План закрытия хвоста v1.x - -**Статус:** в работе. **Целевой релиз:** `v1.0.0` / `ghcr.io/mixeme/selfpost:1.0.0`. - -**Временный файл:** после закрытия v1.x удалить (история — git + CHANGELOG). Не часть постоянного пакета docs. - -**Контекст:** релизный гейт по коду закрыт (B.1–B.3, C.4 e2e, § D Fable). Остаток — [roadmap.md](roadmap.md) § «v1.x — хвост документации и деплоя» + код из того же секции. - -**Политика docs:** `docs/archive/` не храним; устаревшие планы удаляем после переноса полезного в [development.md](development.md). - ---- - -## Рекомендация по модели - -Общее правило (переносится в development.md): - -| Тип работы | Модель | Примеры в этом плане | -|------------|--------|---------------------| -| Безопасность, инфра, права файлов, postfix/postqueue, open-relay-риски | **Opus** | mail.log в `/data`, entrypoint permissions, postqueue reconcile | -| UI/JS/CSS, шаблоны, документация (English), README | **Sonnet** | adaptive polling, development.md § Documentation, README index | -| Тривиальная механика: retarget ссылок, grep, compose bump, CHANGELOG cut | **Haiku** | Makefile/release.yml комментарии, удаление файлов | -| Ревизия безопасности (**review**, не authorship) | **Fable** | не в этом плане (§ D уже выполнен) | - -| Фаза / коммит | Модель | Почему | -|---------------|--------|--------| -| 1. Polling | Sonnet | panel.js + HTMX, CSP | -| 2. mail.log + reconcile | Opus (+ Sonnet на unit-тесты) | postfix path, logrotate, entrypoint, postqueue | -| 3. Docs consolidation | Sonnet | development.md, README, agent-rules | -| 4. Release `1.0.0` | Haiku / Sonnet | механика релиза, без новой логики | -| 5. Tag / push | **Оператор** | явная команда | - -Сообщение коммита: трейлер `Co-Authored-By: Claude <модель> ` с моделью, которая делала шаг. - ---- - -## Общий чек-лист прогресса - -- [x] **Фаза 1** — адаптивный опрос мониторинга -- [x] **Фаза 2** — mail.log в `/data` + postqueue reconcile -- [x] **Фаза 3** — docs: development.md, README, удаление планов и `docs/archive/` -- [ ] **Фаза 4** — релизный коммит `1.0.0` (по явной команде) -- [ ] **Фаза 5** — tag `v1.0.0` + push (по явной команде) -- [ ] **Фаза 6** — выкат на прод (оператор) - -**Гейт перед тегом:** - -- [ ] `gofmt -l .` чистый -- [ ] `go vet ./...` чистый -- [ ] `go test ./...` чистый (Windows-падения domain/logtail — известны, не блокер) -- [ ] `make e2e` зелёный (если Docker доступен) -- [ ] grep: нет ссылок на удалённые docs в живых файлах - ---- - -## Фаза 1 — Адаптивный опрос мониторинга - -**Модель:** Sonnet. - -**Цель:** 5 s при активности, 30 s при простое; скрытая вкладка — 0 запросов. Без `hx-trigger="every 5s [expr]"` (CSP / `unsafe-eval`). - -### Чек-лист - -- [x] `status_body.html`, `mail_queue_body.html`, `system_log_body.html`, `deliveries_rows.html` — `hx-trigger="load"` + маркер polling -- [x] `panel.js` — `lastActivity`, schedule после `htmx:afterRequest` (5s / 30s) -- [x] `htmx:afterSwap` — реинициализация polling-элементов -- [x] `beforeRequest` для hidden tab — оставлен -- [x] `architecture.md` — одна строка про polling -- [x] CHANGELOG `[Unreleased]` -- [ ] Стенд (опционально): idle → ~30s; активность → ~5s; hidden → 0 запросов - ---- - -## Фаза 2 — Send-log vs `mail.log` - -**Модель:** Opus (infra); Sonnet (тесты). - -### 2.1 `mail.log` → `/data/log/mail.log` - -- [x] `build/postfix-config.sh` — `maillog_file` из `MAIL_LOG` -- [x] `build/logrotate-mail.conf` — путь + `create 0640 postfix selfpost` -- [x] `build/entrypoint.sh` — `mkdir`, права (`2750 postfix:selfpost`, файл `0640`), - исключение `/data/log` из общего `chown` на `panel` -- [x] `cmd/panel/main.go` — default `MAIL_LOG` -- [x] `internal/backup` — исключить `log/` из архива (+ тест) -- [x] `test/e2e/mail_helpers.go` — путь в контейнере -- [x] `guide.md` — `MAIL_LOG`, System log, бэкап `./data`; README — рост диска -- [x] `envdoc_test` — `MAIL_LOG` в `buildScriptKeys` - -### 2.2 Postqueue reconcile - -- [x] `internal/postfix` — `QueueIDs` / парсер queue-id из `postqueue -p` + тест -- [x] `internal/store` — `ListQueuedOlderThan(cutoff)` -- [x] `internal/logtail` — periodic sweep (5 min), grace 2 min → `bounced`, - старт только после того, как tailer дочитал лог до конца -- [x] Тесты reconcile - -### 2.3 Документация и риски - -- [x] `architecture.md` — `/data/log`, reconcile; gap «container recreate» убран -- [x] `security.md` — риск «вечный queued» снят; на его месте — ложный `bounced` - и права на лог -- [x] CHANGELOG `[Unreleased]` - -**Стенд (остаток, для оператора):** Docker на машине разработки недоступен — -сборка образа и старт контейнера не проверены. Проверить при выкате: `/data/log` -создаётся с нужными правами, панель читает `mail.log`, logrotate проворачивает -файл, `postqueue -p` читается из-под `panel`. - ---- - -## Фаза 3 — Документация - -**Модель:** Sonnet. - -### 3.1 `documentation-plan.md` → development.md, затем delete - -- [x] development.md § **Documentation**: - - [x] Documentation map (без archive; история = git + CHANGELOG) - - [x] User-facing deliverables - - [x] Maintaining documentation (§3 правила) - - [x] Verifying docs against code (полная таблица §2) -- [x] `architecture.md` шапка → development.md -- [x] Удалить `documentation-plan.md` -- [x] Retarget `roadmap.md` - -### 3.2 `progress.md` → development.md, затем delete - -- [x] development.md § **Resuming work** -- [x] development.md § **Model routing** -- [x] development.md § **Commits** (слить с Release build) -- [x] development.md § **Phase closure** (roadmap, не progress) -- [x] `agent-rules.mdc` → development.md -- [x] Удалить `progress.md` -- [x] Retarget все ссылки - -### 3.3 Удалить `docs/archive/` - -- [x] Удалить `docs/archive/specification-v1.0.md` -- [x] Удалить каталог `docs/archive/` -- [x] Убрать ссылки на archive из живых docs (CHANGELOG историю ниже 0.6.0 не трогать) - -### 3.4 README - -- [x] § Documentation: operator docs + roadmap (internal, Russian) -- [x] Без секции Archive - -### 3.5 Проверка - -- [x] `gofmt` / `vet` / `test` -- [x] grep живых ссылок на удалённые файлы - ---- - -## Фаза 4 — Релизный коммит R1 - -**Модель:** Haiku / Sonnet. **Только по явной команде оператора.** - -- [ ] CHANGELOG: `[Unreleased]` → `[1.0.0] - дата`, новая `[Unreleased]` -- [ ] `deploy/docker-compose.yml` → `ghcr.io/mixeme/selfpost:1.0.0` -- [ ] Удалить `implementation-plan.md` (если ещё есть) -- [ ] Makefile, `.github/workflows/release.yml`, `test/e2e/main_test.go` → development.md -- [ ] `roadmap.md` — секция v1.x **закрыта**; убрать «переместить в archive» -- [ ] Финальный grep ссылок -- [ ] Удалить этот файл (`v1.x-closure-plan.md`) в том же или следующем коммите - ---- - -## Фаза 5 — Tag и push - -**Оператор.** Явная команда. - -```sh -git tag v1.0.0 -git push origin v1.0.0 -``` - -- [ ] `release.yml` — build amd64+arm64, e2e gate, push GHCR `1.0.0` -- [ ] compose tag и git tag совпадают - ---- - -## Фаза 6 — Прод (оператор) - -- [ ] `docker compose pull` → `1.0.0` -- [ ] Разлогин (cookie `__Host-`) -- [ ] Reverse-proxy передаёт `Host` -- [ ] `./data/log/` создался после upgrade - ---- - -## Критерий «хвост v1.x закрыт» - -- [ ] Все чек-листы фаз 1–4 отмечены -- [ ] `compose` = `1.0.0`, тег `v1.0.0` в репо -- [ ] Нет `implementation-plan.md`, `documentation-plan.md`, `progress.md`, `docs/archive/`, `v1.x-closure-plan.md` -- [ ] Нет активных ссылок на удалённые пути -- [ ] `development.md` — процесс + Documentation + model routing -- [ ] `roadmap.md` — единственный internal tracker -- [ ] Polling 5s/30s; mail.log в `/data`; reconcile; риск security снят - ---- - -## Разбивка коммитов - -1. **Polling** — panel.js, templates, architecture (+ CHANGELOG) -2. **mail.log + reconcile** — build, logtail, store, postfix, docs (+ CHANGELOG) -3. **Docs** — development.md, README, agent-rules; delete documentation-plan, progress, archive; retarget (+ CHANGELOG) -4. **Release 1.0.0** — CHANGELOG cut, compose, delete implementation-plan, roadmap closure, delete `v1.x-closure-plan.md` -5. **Tag/push** — оператор diff --git a/test/e2e/compose.override.yml b/test/e2e/compose.override.yml index ee6de56..6b5c38c 100644 --- a/test/e2e/compose.override.yml +++ b/test/e2e/compose.override.yml @@ -16,6 +16,10 @@ services: selfpost: + # Crash-looping with unless-stopped makes `compose exec` fail with + # "Container is restarting" and hides the entrypoint/supervisord exit + # reason; keep the stand exited so logs stay attached to one attempt. + restart: "no" build: # Resolved relative to --project-directory (.stage), NOT this file's own # directory — compose build.context paths follow the project directory, diff --git a/test/e2e/hostname_gate_test.go b/test/e2e/hostname_gate_test.go index f44b0e3..b060949 100644 --- a/test/e2e/hostname_gate_test.go +++ b/test/e2e/hostname_gate_test.go @@ -16,6 +16,11 @@ import ( func runEntrypoint(t *testing.T, hostnameEnv string) (output string, exitedZero bool) { t.Helper() dataDir := t.TempDir() + // Entrypoint also chmod 755 /data; mirror that here so a pre-fix image + // still gets a traversable bind mount under Go's 0700 TempDir. + if err := os.Chmod(dataDir, 0o755); err != nil { + t.Fatalf("chmod data dir: %v", err) + } args := []string{ "run", "--rm", @@ -72,13 +77,22 @@ func TestHostnameGate(t *testing.T) { func runEntrypointBackground(t *testing.T, hostnameEnv string) (output string, started bool) { t.Helper() dataDir := t.TempDir() + if err := os.Chmod(dataDir, 0o755); err != nil { + t.Fatalf("chmod data dir: %v", err) + } certDir := t.TempDir() if err := writeSelfSignedCert(certDir+"/fullchain.pem", certDir+"/privkey.pem"); err != nil { t.Fatalf("generate throwaway TLS cert: %v", err) } name := "selfpost-e2e-hostname-check" _ = exec.Command("docker", "rm", "-f", name).Run() - defer exec.Command("docker", "rm", "-f", name).Run() + defer func() { + // Make bind-mounted /data deletable by Go's TempDir cleanup: the panel + // leaves setup-token/db/opendkim owned by container UIDs. + _ = exec.Command("docker", "exec", name, "sh", "-c", + "chown -R root:root /data && chmod -R a+rwX /data").Run() + _ = exec.Command("docker", "rm", "-f", name).Run() + }() up := exec.Command("docker", "run", "-d", "--name", name, "-e", "SELFPOST_HOSTNAME="+hostnameEnv, diff --git a/test/e2e/main_test.go b/test/e2e/main_test.go index 2eb58ba..4135598 100644 --- a/test/e2e/main_test.go +++ b/test/e2e/main_test.go @@ -1,13 +1,13 @@ -// Package e2e is the hermetic container e2e gate (plan C.4): it drives the -// shipped deploy/docker-compose.yml (plus a test-only override) exactly as an +// Package e2e is the hermetic container e2e gate: it drives the shipped +// deploy/docker-compose.yml (plus a test-only override) exactly as an // administrator and their applications would, so the class of failure unit // tests cannot see — broken container wiring — has one place to be caught // before an image is published. // -// It is a separate module on purpose (see ../../docs/implementation-plan.md, -// item C.4): `go test ./...` in the main module never pulls this in, and its -// test-only dependencies (DKIM verification) never enter the shipped -// binaries' build graph. +// It is a separate module on purpose (see ../../docs/development.md): +// `go test ./...` in the main module never pulls this in, and its test-only +// dependencies (DKIM verification) never enter the shipped binaries' build +// graph. package e2e import ( @@ -54,6 +54,10 @@ func TestMain(m *testing.M) { fmt.Fprintf(os.Stderr, "\n==== logs: %s ====\n%s\n", svc, s.logs(svc)) } } + // Panel/postfix-owned files under the /data bind mount outlive the + // container; reclaim ownership while selfpost is still up so a later + // prepareStage RemoveAll (or a local re-run) is not stuck on EACCES. + s.reclaimData() s.down() os.Exit(code) } @@ -87,14 +91,27 @@ type scenario struct { func TestE2E(t *testing.T) { sc := &scenario{} - t.Run("startup_processes_running", func(t *testing.T) { + // Ordered scenario: each step needs state from earlier ones. A failed + // subtest must stop the rest — otherwise sc.panel stays nil and the next + // step panics, masking the real failure (as seen on the v1.0.0 release CI). + run := func(name string, fn func(*testing.T)) { + if t.Failed() { + return + } + t.Run(name, fn) + } + + run("startup_processes_running", func(t *testing.T) { if err := checkSupervisorProcesses(h); err != nil { t.Fatal(err) } + if err := waitForPanelReady(); err != nil { + t.Fatal(err) + } }) - t.Run("setup_and_login", func(t *testing.T) { - token, err := readSetupToken(h.stageDir) + run("setup_and_login", func(t *testing.T) { + token, err := readSetupToken(h) if err != nil { t.Fatalf("read setup token: %v", err) } @@ -111,7 +128,7 @@ func TestE2E(t *testing.T) { sc.panel = p }) - t.Run("add_domain_and_publish_dkim", func(t *testing.T) { + run("add_domain_and_publish_dkim", func(t *testing.T) { id, err := sc.panel.addDomain(senderDomain) if err != nil { t.Fatalf("add domain: %v", err) @@ -137,7 +154,7 @@ func TestE2E(t *testing.T) { sc.zoneRecords = records }) - t.Run("add_application", func(t *testing.T) { + run("add_application", func(t *testing.T) { login, password, err := sc.panel.addApplication(sc.domainID, "app1", "wildcard", "") if err != nil { t.Fatalf("add application: %v", err) @@ -145,7 +162,7 @@ func TestE2E(t *testing.T) { sc.appLogin, sc.appPassword = login, password }) - t.Run("send_verify_dkim_and_status", func(t *testing.T) { + run("send_verify_dkim_and_status", func(t *testing.T) { token := uniqueToken("positive") res := attemptSend(sendAttempt{ authLogin: sc.appLogin, authPassword: sc.appPassword, @@ -179,25 +196,25 @@ func TestE2E(t *testing.T) { } }) - t.Run("negative_level2_ratelimit_via_panel", func(t *testing.T) { + run("negative_level2_ratelimit_via_panel", func(t *testing.T) { testLevel2RateLimit(t, sc) }) - t.Run("negative_sender_login_mismatch", func(t *testing.T) { + run("negative_sender_login_mismatch", func(t *testing.T) { testSenderLoginMismatch(t, sc) }) - t.Run("negative_no_auth_rejected", func(t *testing.T) { + run("negative_no_auth_rejected", func(t *testing.T) { testNoAuthRejected(t, sc) }) - t.Run("negative_foreign_relay_rejected", func(t *testing.T) { + run("negative_foreign_relay_rejected", func(t *testing.T) { testForeignRelayRejected(t, sc) }) - t.Run("negative_journal_milter_fail_open", func(t *testing.T) { + run("negative_journal_milter_fail_open", func(t *testing.T) { testJournalMilterFailOpen(t, sc) }) - t.Run("session_survives_restart", func(t *testing.T) { + run("session_survives_restart", func(t *testing.T) { testSessionSurvivesRestart(t, sc) }) - t.Run("negative_level1_ratelimit", func(t *testing.T) { + run("negative_level1_ratelimit", func(t *testing.T) { testLevel1RateLimit(t, sc) }) } diff --git a/test/e2e/panel_client.go b/test/e2e/panel_client.go index a42ab54..2b16495 100644 --- a/test/e2e/panel_client.go +++ b/test/e2e/panel_client.go @@ -7,8 +7,6 @@ import ( "net/http" "net/http/cookiejar" "net/url" - "os" - "path/filepath" "regexp" "strings" "time" @@ -39,22 +37,48 @@ func newPanelClient() (*panelClient, error) { }}, nil } -// readSetupToken reads the one-time setup URL SelfPost wrote to /data (bind -// mounted at stageDir/data/setup-token) and returns just the token. -func readSetupToken(stageDir string) (string, error) { - var raw []byte - err := waitFor("setup-token to appear", 30*time.Second, 300*time.Millisecond, func() (bool, error) { - b, err := os.ReadFile(filepath.Join(stageDir, "data", "setup-token")) +// waitForPanelReady polls the host-published panel port until /healthz +// returns 200. Supervisor reporting the panel process RUNNING is not enough: +// the setup-token file is written in Start() before ListenAndServe, and +// Docker's host-port publish can lag the in-container bind — either race +// makes the first setup POST fail and leaves sc.panel nil for later steps. +func waitForPanelReady() error { + client := &http.Client{Timeout: 2 * time.Second} + return waitFor("panel /healthz on "+panelBaseURL, 60*time.Second, 200*time.Millisecond, func() (bool, error) { + resp, err := client.Get(panelBaseURL + "/healthz") if err != nil { return false, err } - raw = b - return len(b) > 0, nil + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + if resp.StatusCode != http.StatusOK { + return false, fmt.Errorf("status %d", resp.StatusCode) + } + return true, nil + }) +} + +// readSetupToken reads the one-time setup URL from /data/setup-token inside +// the running selfpost container. The file is mode 0600 owned by the panel +// UID (security.md / setup.go); on a typical CI bind mount that is not the +// host runner's UID, so a host-side os.ReadFile returns permission denied +// even though the panel already wrote the token (CI: setup_and_login). +// Reading via compose exec matches docs/guide.md ("docker compose exec +// selfpost cat /data/setup-token"). +func readSetupToken(s *stack) (string, error) { + var raw string + err := waitFor("setup-token to appear", 30*time.Second, 300*time.Millisecond, func() (bool, error) { + out, err := s.execIn("selfpost", "cat", "/data/setup-token") + if err != nil { + return false, err + } + raw = out + return strings.TrimSpace(out) != "", nil }) if err != nil { return "", err } - u, err := url.Parse(strings.TrimSpace(string(raw))) + u, err := url.Parse(strings.TrimSpace(raw)) if err != nil { return "", fmt.Errorf("parse setup token file: %w", err) } diff --git a/test/e2e/process_check.go b/test/e2e/process_check.go index 4b8ff05..399a234 100644 --- a/test/e2e/process_check.go +++ b/test/e2e/process_check.go @@ -45,7 +45,10 @@ func checkSupervisorProcesses(s *stack) error { } return true, nil }) - return err + if err != nil { + return fmt.Errorf("%w\n==== selfpost logs ====\n%s", err, s.logs("selfpost")) + } + return nil } func parseSupervisorStatus(out string) map[string]string { diff --git a/test/e2e/stack.go b/test/e2e/stack.go index f39bb06..879af14 100644 --- a/test/e2e/stack.go +++ b/test/e2e/stack.go @@ -106,6 +106,14 @@ func (s *stack) down() { _, _ = s.compose("down", "-v", "--remove-orphans") } +// reclaimData chowns/chmods the /data bind mount from inside the still-running +// selfpost container so the host test user can delete it afterwards. Files +// written as panel/postfix (setup-token 0600, opendkim tree, sqlite) otherwise +// leave EACCES on TempDir/stage cleanup (CI hostname-gate + prepareStage). +func (s *stack) reclaimData() { + _, _ = s.execIn("selfpost", "sh", "-c", "chown -R root:root /data && chmod -R a+rwX /data") +} + // logs returns a service's combined stdout/stderr, for failure diagnostics. func (s *stack) logs(service string) string { out, _ := s.compose("logs", "--no-color", service) diff --git a/test/e2e/stage.go b/test/e2e/stage.go index c036a75..3f51391 100644 --- a/test/e2e/stage.go +++ b/test/e2e/stage.go @@ -9,6 +9,7 @@ import ( "fmt" "math/big" "os" + "os/exec" "path/filepath" "time" ) @@ -23,7 +24,7 @@ const selfpostHostname = "mail.e2e.test" // CoreDNS is authoritative for, and the sink-MX's dump directory. Called once // per run before `docker compose up`, so every run starts from a clean slate. func prepareStage(s *stack) error { - if err := os.RemoveAll(s.stageDir); err != nil { + if err := removeAllBestEffort(s.stageDir); err != nil { return fmt.Errorf("clean stage dir: %w", err) } dirs := []string{"data", "certs", "dns-stage", "mail-stage"} @@ -48,6 +49,17 @@ func prepareStage(s *stack) error { return writeZone(s.stageDir, nil) } +// removeAllBestEffort deletes path; if a previous run left container-UID files +// on the bind mount, a root alpine one-shot removes them first. +func removeAllBestEffort(path string) error { + if err := os.RemoveAll(path); err == nil { + return nil + } + _ = exec.Command("docker", "run", "--rm", "-v", path+":/wipe", "alpine:3.20", + "sh", "-c", "rm -rf /wipe/..?* /wipe/.[!.]* /wipe/*").Run() + return os.RemoveAll(path) +} + // writeSelfSignedCert generates a throwaway RSA key + self-signed certificate // for selfpostHostname, valid for a day — this stand never outlives that. // Postfix's smtpd_tls_security_level is "may" (opportunistic), not enforced,