From f3bc24b638c416cb25bc25682329e43944df68be Mon Sep 17 00:00:00 2001 From: Mikhail Yenuchenko Date: Mon, 3 Aug 2026 00:29:14 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20C.4=20=E2=80=94=20hermetic?= =?UTF-8?q?=20container=20e2e=20gate=20+=20native=20release=20build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separate test/e2e Go module drives the shipped deploy/docker-compose.yml (plus a test-only override: self-signed cert, low ports, isolated compose project) against a fake DNS zone (CoreDNS) and an smtp-sink MX, exactly as an administrator and their applications would over HTTP/SMTP — covering the class of failure unit tests can't see (container wiring). Positive path: setup -> login -> domain -> DKIM record published into the fake zone -> application -> SMTP AUTH send -> DKIM verified against the DNS-published key -> send-log queued->sent. Negative: no-AUTH/unauthenticated relay, sender/login mismatch, L1 (anvil) and L2 (panel) rate limits, journal-milter fail-open, SELFPOST_HOSTNAME gate, session survives docker restart. release.yml moves off qemu to a native per-arch build (amd64/arm64), each gated by this suite before its tag is pushed and merged into the version manifest. Verified green on selfpost.example.com via `make e2e`; go vet/gofmt clean in both modules. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 86 +++++++++--- .gitignore | 4 + CHANGELOG.md | 19 +++ Makefile | 12 +- docs/progress.md | 3 +- test/e2e/compose.override.yml | 82 ++++++++++++ test/e2e/dns/Corefile | 16 +++ test/e2e/dns_zone.go | 120 +++++++++++++++++ test/e2e/go.mod | 7 + test/e2e/go.sum | 4 + test/e2e/hostname_gate_test.go | 101 ++++++++++++++ test/e2e/mail_helpers.go | 86 ++++++++++++ test/e2e/main_test.go | 236 +++++++++++++++++++++++++++++++++ test/e2e/negative_test.go | 224 +++++++++++++++++++++++++++++++ test/e2e/panel_client.go | 234 ++++++++++++++++++++++++++++++++ test/e2e/process_check.go | 61 +++++++++ test/e2e/sink/Dockerfile | 26 ++++ test/e2e/smtp_client.go | 142 ++++++++++++++++++++ test/e2e/stack.go | 113 ++++++++++++++++ test/e2e/stage.go | 86 ++++++++++++ test/e2e/wait.go | 29 ++++ 21 files changed, 1674 insertions(+), 17 deletions(-) create mode 100644 test/e2e/compose.override.yml create mode 100644 test/e2e/dns/Corefile create mode 100644 test/e2e/dns_zone.go create mode 100644 test/e2e/go.mod create mode 100644 test/e2e/go.sum create mode 100644 test/e2e/hostname_gate_test.go create mode 100644 test/e2e/mail_helpers.go create mode 100644 test/e2e/main_test.go create mode 100644 test/e2e/negative_test.go create mode 100644 test/e2e/panel_client.go create mode 100644 test/e2e/process_check.go create mode 100644 test/e2e/sink/Dockerfile create mode 100644 test/e2e/smtp_client.go create mode 100644 test/e2e/stack.go create mode 100644 test/e2e/stage.go create mode 100644 test/e2e/wait.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6926f90..b64c5c9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,26 +6,87 @@ name: release # into both the image tag and the panel binary's -ldflags version, so the two # 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: +# 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 +# manifest tag documented in deploy/docker-compose.yml. "test, then push" (not +# push-by-digest then test) is deliberate: it means the bytes that get tagged +# are exactly the bytes that passed e2e, at the cost of per-arch tags lingering +# in the registry as a side effect (harmless — the version tag's immutability, +# spec 10.1, is about that tag, not these). on: push: tags: - "v[0-9]+.[0-9]+.[0-9]+" + workflow_dispatch: permissions: contents: read packages: write jobs: - build-and-push: + prepare: runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} steps: - - uses: actions/checkout@v4 - - name: Derive version from tag id: version run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" - - uses: docker/setup-qemu-action@v3 + build: + needs: prepare + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-latest + arch: amd64 + - runner: ubuntu-24.04-arm + arch: arm64 + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Build image (native, loaded locally for the e2e gate) + uses: docker/build-push-action@v6 + with: + context: . + file: build/Dockerfile + load: true + provenance: false + build-args: | + VERSION=${{ needs.prepare.outputs.version }} + tags: selfpost:e2e + + - uses: actions/setup-go@v5 + with: + go-version: "1.26" + cache-dependency-path: test/e2e/go.sum + + - name: e2e (gates publishing — see docs/implementation-plan.md C.4) + run: cd test/e2e && go test -v -timeout 20m ./... + + - name: Log in to ghcr.io + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Push per-arch tag + run: | + docker tag selfpost:e2e "ghcr.io/${{ github.repository }}:${{ needs.prepare.outputs.version }}-${{ matrix.arch }}" + docker push "ghcr.io/${{ github.repository }}:${{ needs.prepare.outputs.version }}-${{ matrix.arch }}" + + merge: + needs: [prepare, build] + runs-on: ubuntu-latest + steps: - uses: docker/setup-buildx-action@v3 - name: Log in to ghcr.io @@ -35,14 +96,9 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: . - file: build/Dockerfile - platforms: linux/amd64,linux/arm64 - build-args: | - VERSION=${{ steps.version.outputs.version }} - push: true - provenance: false - tags: ghcr.io/${{ github.repository }}:${{ steps.version.outputs.version }} + - name: Merge per-arch tags into the version manifest + run: | + docker buildx imagetools create \ + -t "ghcr.io/${{ github.repository }}:${{ needs.prepare.outputs.version }}" \ + "ghcr.io/${{ github.repository }}:${{ needs.prepare.outputs.version }}-amd64" \ + "ghcr.io/${{ github.repository }}:${{ needs.prepare.outputs.version }}-arm64" diff --git a/.gitignore b/.gitignore index 143871f..b204197 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,7 @@ dev/ # Local Go caches / test output, if ever present. *.out + +# e2e scratch stand (generated cert, DKIM zone, sink dump, /data bind mount) +# — recreated fresh by every `make e2e` run, never checked in. +/test/e2e/.stage/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 2df2907..8223b03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ## [Unreleased] +- ci: hermetic container e2e suite (`test/e2e`, a separate Go module) gates + image publishing — `make e2e` locally, and `go test ./...` in `test/e2e` as + a required step in `release.yml` before a version tag's image is pushed. + It builds the real image, brings up the shipped `deploy/docker-compose.yml` + plus a test-only override (self-signed cert, low ports, a fake DNS zone + served by CoreDNS, a `smtp-sink` sink-MX) on an isolated compose project, + then drives the panel over HTTP exactly like an administrator: setup → + login → add a domain → publish the DKIM record it prints into the fake zone + → add an application → send over SMTP AUTH → verify the delivered message's + DKIM signature against the record the panel published → poll the send log + to `sent`. Negative coverage: no-AUTH and unauthenticated-relay rejection, + sender/login mismatch, the level-1 (anvil) and level-2 (panel-configured) + rate limits, the journal-milter's fail-open behaviour when the panel process + is stopped, a missing/malformed `SELFPOST_HOSTNAME` failing the container + fast, and a login session surviving `docker restart`. `release.yml` moved + off qemu to a native per-architecture build (`ubuntu-latest` / + `ubuntu-24.04-arm`), each gated by this suite before its tag is pushed and + merged into the version manifest — running the full Postfix/OpenDKIM stack + under emulation for the gate was impractically slow. - ops: `mail.log` rotation switched from `copytruncate` to rename + `postfix reload` (the same mechanism `postfix logrotate` itself uses), eliminating the up-to-one-second window in which `copytruncate` could drop diff --git a/Makefile b/Makefile index 42e3305..5ac55d0 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ MODULE := codeberg.org/mix/selfpost LDFLAGS := -X $(MODULE)/internal/buildinfo.Version=$(VERSION) GOFLAGS := -trimpath -.PHONY: all build vet test clean +.PHONY: all build vet test clean e2e all: vet build @@ -29,3 +29,13 @@ test: clean: rm -rf bin + +# Hermetic container e2e (plan implementation-plan.md C.4): 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 +# isolated compose project (-p selfpost-e2e) so it never collides with a real +# deployment on the same host, then tears the stand down whether the suite +# passed or not. +e2e: + cd test/e2e && go test -v -timeout 20m ./... diff --git a/docs/progress.md b/docs/progress.md index 61cb5c9..778bd10 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -41,7 +41,8 @@ - **B.1 реализован** (не выкачен на прод): сессии переехали в SQLite (`internal/store/migrations/0002_sessions.sql`, `internal/store/sessions.go`, `internal/web/session.go`) — хранится SHA-256 токена, не сам токен; скользящий срок бездействия `PANEL_SESSION_IDLE_DAYS` (по умолчанию 7 дней, без абсолютного потолка); запись в БД продлевается не чаще раза в час (`renewThreshold`); опросы мониторинга (`GET` с `HX-Request`) продление не триггерят (`isSessionActivity` в `internal/web/middleware.go`); `Max-Age` cookie выставляется тем же значением при логине и при продлении (`setSessionCookie`); смена пароля разлогинивает все сессии кроме текущей (уже было, теперь через БД). Проверено на стенде: логин → рестарт процесса панели → сессия жива по старой cookie; HX-Request-опрос и повторный GET внутри часового окна не шлют `Set-Cookie`. `go vet`/`go test ./...`/`gofmt -l .` чистые. - **B.2 реализован** (не выкачен на прод): ротация `mail.log` ушла с `copytruncate` на «переименовать + `postfix reload`» — `build/logrotate-mail.conf` (`nocreate` заменён на `create 0644 root root` **не по плану, а по стендовой проверке**: после reload Postfix пересоздаёт лог сам только в момент следующей фактической записи и с режимом `0600`, недоступным непривилегированной панели, — `create` в logrotate закрывает это, отдавая файл ей же на 644 сразу после переименования); `follow()` в `internal/logtail/logtail.go` при обнаружении смены inode дочитывает старый дескриптор ещё раз перед переключением; `readLogTail()` в `internal/web/handlers_monitor.go` считает отсутствующий файл пустым экраном, а не ошибкой. Проверено на стенде (`selfpost.example.com`, отдельный контейнер `selfpost:b2test2`): цикл трафик → принудительная ротация → файл пуст и сразу читаем непривилегированным uid панели (0 читает `mail.log` сразу после rename, без окна недоступности) → новый трафик после ротации уходит в новый файл на 644, ничего не потеряно по обе стороны rename. `go vet`/`go test ./...`/`gofmt -l .` чистые (на dev-сервере; локально на Windows `TestFollowTailsAndRotates` падает — rename открытого файла запрещён ОС, к делу не относится). - **B.3 реализован** (не выкачен на прод): `build/entrypoint.sh` проверяет `SELFPOST_HOSTNAME` до `postfix-config.sh` и до `supervisord` — при пустом значении `exit 1` с развёрнутым текстом ошибки (что это за имя, почему обязательно, пример, где задаётся); плюс синтаксическая проверка через `case`: минимум одна точка, без схемы/порта/пробелов (`*://*`, `*:*`, пробел/таб — тот же класс тихого спам-отказа, что и пустое значение). `saslRealm()` и fallback в `postfix-config.sh` не тронуты — после гейта эти ветки мертвы. Заодно отмечена обязательность переменной в `README.md` и `deploy/.env.example`. Проверено на стенде (`selfpost.example.com`, отдельный образ `selfpost:b3test`, cap-list как в поставляемом compose): без переменной — `exit 1` с ожидаемым текстом, без бесконечного тихого retry; `https://mail.example.com:465` и `localhost` отклонены с понятными сообщениями; валидный `mail.example.com` — обычный старт, все процессы supervisord поднимаются. `go vet`/`go test ./...` чистые. -- **Решено, но ещё не реализовано:** пункт **C.4** плана. Герметичный контейнерный e2e отдельным Go-модулем `test/e2e/` поверх поставляемого compose, гейт перед публикацией образа по тегу, нативная матрица amd64/arm64 вместо qemu в `release.yml`; делается **после** B.1–B.3 (сделаны), стендовые проверки B.1/B.3 переезжают в него регрессиями. Замыкает очередь **D.5** — предрелизная проверка на уязвимости моделью Fable по всему дифу от `v1.0.0` плюс повторный проход по ТЗ 7.6; вместе с e2e это гейт перед тегом. +- **C.4 реализован** (не выкачен на прод — это CI/тестовая инфраструктура, а не образ): герметичный контейнерный e2e отдельным Go-модулем `test/e2e/` (свой `go.mod`, не подхватывается `go test ./...` основного модуля) поверх поставляемого `deploy/docker-compose.yml` плюс `test/e2e/compose.override.yml` (самоподписанный сертификат, `PANEL_COOKIE_SECURE=false`, `SELFPOST_HOSTNAME=mail.e2e.test`, высокие порты `20465/20587/20080`, изолированный compose-проект `selfpost-e2e`, свой `--project-directory` — прод на том же хосте не задет). Герметичная почта: CoreDNS (`test/e2e/dns/Corefile` — авторитетна только для `e2e.test`, `file`-плагин с саб-директивой `reload` перечитывает `db.zone` по mtime, без сигналов) плюс `smtp-sink` из пакета postfix (`test/e2e/sink/`) как sink-MX. Сценарий (`test/e2e/*_test.go`): старт контейнера → все supervisord-программы `RUNNING` (`postfix-reload` — `STOPPED`) → токен из `/data/setup-token` → setup → login → добавление домена → DKIM-запись **скраплена со страницы панели** и опубликована в фейковую зону → добавление приложения → SMTP AUTH на 465 → письмо на sink → DKIM-подпись проверена (`go-msgauth/dkim` с кастомным `LookupTXT` через CoreDNS) против ключа **из DNS**, не из панели напрямую → send-log `queued → sent`. Негативы: без AUTH, relay на чужой домен без AUTH, sender/login mismatch (`reject_sender_login_mismatch` репортится Postfix'ом на RCPT, не MAIL — `smtpd_delay_reject=yes` по умолчанию), L1-лимит (anvil, override `RATE_LIMIT_MESSAGES_PER_IP=50` — специально высокий, чтобы остальные под-тесты не расходовали общий бюджет по IP раньше времени; сам тест шлёт до 60 раз, ждёт отказа), L2-лимит через панель (домен/приложение → `rejected`-строка в send-log), fail-open journal-milter'а (`supervisorctl stop panel`, письмо всё равно принято, контейнер жив), пустой/синтаксически неверный `SELFPOST_HOSTNAME` (отдельный один-разовый контейнер, не общий стенд), сессия переживает `docker restart` (плюс явное ожидание готовности smtps-порта после рестарта — панель и Postfix поднимаются независимо). `make e2e` — локальный/dev-server прогон. Найдено и исправлено по ходу стендовой проверки: `reload` — саб-директива `file`-плагина CoreDNS, а не отдельный топ-левел плагин (топ-левел `reload` следит за самим Corefile, не за зоной); `docker compose build.context` резолвится относительно `--project-directory`, а не относительно файла, где объявлен; `smtp-sink` отказывается стартовать от root без `-u`; `html/template` эскейпит `+` в `+` даже в тексте — скрапер значений со страницы обязан `html.UnescapeString`; проверки состояния сразу после `up`/`restart` должны поллиться, а не разово опрашиваться (supervisord/postfix поднимаются не мгновенно). **Проверено на dev-сервере (`selfpost.example.com`)**: `make e2e` — зелёный (`go vet`/`gofmt -l` тоже чистые в обоих модулях). `release.yml` переработан: job `prepare` (версия из тега) → матрица `[ubuntu-latest, ubuntu-24.04-arm]` — каждая нативно собирает образ (`--load`), прогоняет e2e, пушит тег `X.Y.Z-amd64`/`X.Y.Z-arm64` → job `merge` — `docker buildx imagetools create` в единый тег `X.Y.Z`; `setup-qemu-action` убран. Не проверено вживую (нельзя без реального тега): сам workflow на GitHub Actions — синтаксис вычитан, логика идентична локальному `make e2e` пути. +- **Дальше:** пункт **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.example.com`, реальный Let's Encrypt сертификат, живой e2e (DKIM/SPF pass). Контейнер там всё ещё на образе v1.0 — Фаза 14 в него не выкатывалась. При апгрейде: админа один раз разлогинит (сменилось имя cookie), а от reverse-proxy требуется передача исходного `Host` (Apache-фрагмент из `deploy/` это делает). diff --git a/test/e2e/compose.override.yml b/test/e2e/compose.override.yml new file mode 100644 index 0000000..e913561 --- /dev/null +++ b/test/e2e/compose.override.yml @@ -0,0 +1,82 @@ + +# Hermetic e2e stand override for ../../deploy/docker-compose.yml (plan C.4). +# +# Applied as a second -f on top of the SHIPPED compose file, not a separate +# test compose — the whole point is to exercise cap_drop/cap_add/ +# no-new-privileges exactly as documented, not a laxer stand-in. Run only via +# `make e2e`, which also sets --project-directory to a scratch stage +# directory so the base file's relative ./data and ./certs bind mounts land +# there instead of next to a real deployment, and -p selfpost-e2e so the +# containers/network never collide with a prod stack on the same host. +# +# `ports:` and `networks:` use the compose-spec `!override` merge tag to fully +# replace the base file's list rather than concatenate with it (the default +# merge behaviour for sequences) — otherwise the real 465/587/8080 would stay +# published here too and collide with a prod container on the same host. + +services: + selfpost: + build: + # Resolved relative to --project-directory (.stage), NOT this file's own + # directory — compose build.context paths follow the project directory, + # unlike bind-mount volume paths, which is the same reason ./data and + # ./certs above need no path here at all. + context: ../../.. + dockerfile: build/Dockerfile + args: + VERSION: e2e + image: selfpost:e2e + environment: + SELFPOST_HOSTNAME: mail.e2e.test + # Plain HTTP panel for the test client — no reverse proxy in this stand. + PANEL_COOKIE_SECURE: "false" + # High enough that the whole suite's other ~10 sends (positive path, + # level-2 test, the various rejected attempts) never trip it by + # accident — only the dedicated level-1 test (negative check 4, which + # loops well past this count) is meant to hit it. + RATE_LIMIT_MESSAGES_PER_IP: "50" + RATE_LIMIT_WINDOW_SECONDS: "60" + ports: !override + - "20465:465" + - "20587:587" + - "127.0.0.1:20080:8080" + dns: + - 10.77.0.10 + networks: !override + e2e: + depends_on: + - coredns + - sink + + # Fake authoritative DNS for e2e.test (plan C.4): serves the DKIM/host + # records the test harness publishes, so Postfix's outbound MX lookup for + # the sink and the panel's own dnscheck resolve inside the isolated network + # instead of hitting the real internet. + coredns: + image: coredns/coredns:latest + command: ["-conf", "/dns/Corefile"] + volumes: + - ./dns-stage:/dns + networks: + e2e: + ipv4_address: 10.77.0.10 + ports: + - "20053:53/udp" + + # Sink-MX: accepts any inbound mail and dumps each transaction to its own + # file under ./mail-stage, which the harness reads directly to check the + # DKIM-Signature header Postfix/OpenDKIM produced. + sink: + build: + context: ../sink + volumes: + - ./mail-stage:/mail + networks: + e2e: + ipv4_address: 10.77.0.11 + +networks: + e2e: + ipam: + config: + - subnet: 10.77.0.0/24 diff --git a/test/e2e/dns/Corefile b/test/e2e/dns/Corefile new file mode 100644 index 0000000..89c2eb5 --- /dev/null +++ b/test/e2e/dns/Corefile @@ -0,0 +1,16 @@ +# Authoritative fake zone for the e2e stand (plan C.4). Only e2e.test and its +# subdomains are served — everything else is refused, which is fine: neither +# the panel nor Postfix ever needs to resolve anything outside this zone here. +# +# The `file` plugin's own `reload` sub-directive (NOT the top-level `reload` +# plugin, which only watches the Corefile itself) polls db.zone's mtime/serial +# and reloads with no signal needed, so the test harness can rewrite the zone +# file (to publish a domain's DKIM record) and just poll until the new record +# is visible — no container restart, no sleep. +e2e.test { + file /dns/db.zone { + reload 1s + } + log + errors +} diff --git a/test/e2e/dns_zone.go b/test/e2e/dns_zone.go new file mode 100644 index 0000000..82a8420 --- /dev/null +++ b/test/e2e/dns_zone.go @@ -0,0 +1,120 @@ +package e2e + +import ( + "context" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "time" +) + +// dnsAddr is where compose.override.yml publishes CoreDNS's UDP port, so the +// test process (running on the host, not inside the e2e network) can resolve +// through the same fake zone Postfix and the panel see via the `dns:` compose +// directive pointing containers at CoreDNS's static 10.77.0.10. +const dnsAddr = "127.0.0.1:20053" + +// e2eResolver returns a *net.Resolver that only ever talks to the fake zone's +// CoreDNS, for both direct assertions in tests and as the DKIM verifier's +// LookupTXT. +func e2eResolver() *net.Resolver { + return &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, network, dnsAddr) + }, + } +} + +// txtRecord is one name -> TXT value pair the harness publishes into the fake +// zone (the DKIM record a domain's page told us to publish). +type txtRecord struct { + name string // relative to e2e.test, e.g. "selfpost._domainkey.sender" + value string +} + +// writeZone (re)writes the whole authoritative db.zone for e2e.test: the fixed +// records CoreDNS/sink need to be reachable, plus whatever TXT records the +// test has published so far. CoreDNS's `reload 2s` (see dns/Corefile) picks up +// the new mtime with no signal needed. +func writeZone(stageDir string, extra []txtRecord) error { + var b strings.Builder + fmt.Fprintf(&b, "$ORIGIN e2e.test.\n$TTL 300\n") + fmt.Fprintf(&b, "@ IN SOA ns.e2e.test. admin.e2e.test. ( %d 3600 900 604800 300 )\n", time.Now().Unix()) + fmt.Fprintf(&b, "@ IN NS ns.e2e.test.\n") + fmt.Fprintf(&b, "ns IN A 10.77.0.10\n") + // The sink-MX: Postfix's outbound delivery for the recipient domain used by + // every positive-path send resolves here via the domain's implicit MX + // fallback to its own A record (RFC 5321) — no explicit MX record needed. + fmt.Fprintf(&b, "sink IN A 10.77.0.11\n") + for _, r := range extra { + fmt.Fprintf(&b, "%s IN TXT %s\n", r.name, chunkTXT(r.value)) + } + return os.WriteFile(filepath.Join(stageDir, "dns-stage", "db.zone"), []byte(b.String()), 0o644) +} + +// chunkTXT splits a TXT value into <255-byte quoted strings (the DNS +// limit), which resolvers concatenate back into one value. +// A DKIM RSA-2048 public key's base64 comfortably exceeds 255 bytes on its +// own, so this is required, not defensive. +func chunkTXT(value string) string { + const max = 255 + var parts []string + for len(value) > max { + parts = append(parts, value[:max]) + value = value[max:] + } + parts = append(parts, value) + var quoted []string + for _, p := range parts { + quoted = append(quoted, `"`+strings.ReplaceAll(p, `"`, `\"`)+`"`) + } + return strings.Join(quoted, " ") +} + +// publishTXT rewrites the zone with rec added/replacing any prior record of +// the same name, then blocks until CoreDNS actually serves the new value — +// so the caller never has to sleep-and-hope for the reload interval to pass. +func publishTXT(stageDir string, existing []txtRecord, rec txtRecord) ([]txtRecord, error) { + updated := make([]txtRecord, 0, len(existing)+1) + for _, r := range existing { + if r.name != rec.name { + updated = append(updated, r) + } + } + updated = append(updated, rec) + if err := writeZone(stageDir, updated); err != nil { + return nil, err + } + res := e2eResolver() + fqdn := rec.name + ".e2e.test" + err := waitFor(fmt.Sprintf("CoreDNS to serve TXT %s", fqdn), 20*time.Second, 300*time.Millisecond, func() (bool, error) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + txt, err := res.LookupTXT(ctx, fqdn) + if err != nil { + return false, err + } + for _, t := range txt { + if t == rec.value { + return true, nil + } + } + return false, fmt.Errorf("got %v, want %q", txt, rec.value) + }) + return updated, err +} + +// lookupTXTFunc adapts e2eResolver to the shape dkim.VerifyOptions.LookupTXT +// wants (a synchronous domain -> []string call, no context). +func lookupTXTFunc() func(string) ([]string, error) { + res := e2eResolver() + return func(domain string) ([]string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return res.LookupTXT(ctx, domain) + } +} diff --git a/test/e2e/go.mod b/test/e2e/go.mod new file mode 100644 index 0000000..e88d05d --- /dev/null +++ b/test/e2e/go.mod @@ -0,0 +1,7 @@ +module codeberg.org/mix/selfpost/test/e2e + +go 1.26 + +require github.com/emersion/go-msgauth v0.6.8 + +require golang.org/x/crypto v0.15.0 // indirect diff --git a/test/e2e/go.sum b/test/e2e/go.sum new file mode 100644 index 0000000..2f7855e --- /dev/null +++ b/test/e2e/go.sum @@ -0,0 +1,4 @@ +github.com/emersion/go-msgauth v0.6.8 h1:kW/0E9E8Zx5CdKsERC/WnAvnXvX7q9wTHia1OA4944A= +github.com/emersion/go-msgauth v0.6.8/go.mod h1:YDwuyTCUHu9xxmAeVj0eW4INnwB6NNZoPdLerpSxRrc= +golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA= +golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= diff --git a/test/e2e/hostname_gate_test.go b/test/e2e/hostname_gate_test.go new file mode 100644 index 0000000..f44b0e3 --- /dev/null +++ b/test/e2e/hostname_gate_test.go @@ -0,0 +1,101 @@ +package e2e + +import ( + "os" + "os/exec" + "strings" + "testing" + "time" +) + +// runEntrypoint runs the selfpost:e2e image's entrypoint standalone (no +// compose, no DNS/sink sidecars — the SELFPOST_HOSTNAME gate in +// build/entrypoint.sh runs and fails before anything else needs them) with +// hostnameEnv substituted for SELFPOST_HOSTNAME, and returns its combined +// output and whether it exited zero. +func runEntrypoint(t *testing.T, hostnameEnv string) (output string, exitedZero bool) { + t.Helper() + dataDir := t.TempDir() + + args := []string{ + "run", "--rm", + "-e", "SELFPOST_HOSTNAME=" + hostnameEnv, + "-v", dataDir + ":/data", + "selfpost:e2e", + } + cmd := exec.Command("docker", args...) + cmd.Env = os.Environ() + out, err := cmd.CombinedOutput() + return string(out), err == nil +} + +// TestHostnameGate is plan C.4 negative check 7: an empty or syntactically +// invalid SELFPOST_HOSTNAME must fail the container fast with an explanatory +// message (plan B.3), never a silent bad fallback. It runs the image directly +// rather than through the shared stand — the whole point is to exercise +// entrypoint.sh's gate before anything else in the container has a chance to +// start, so it does not depend on TestE2E's stack at all. +func TestHostnameGate(t *testing.T) { + cases := []struct { + name string + hostname string + want string + }{ + {"empty", "", "SELFPOST_HOSTNAME is not set"}, + {"scheme_and_port", "https://mail.example.com:465", "must be a bare hostname"}, + {"bare_word_no_dot", "localhost", "fully-qualified domain name"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + out, ok := runEntrypoint(t, c.hostname) + if ok { + t.Fatalf("container started with SELFPOST_HOSTNAME=%q, want a fatal exit\noutput:\n%s", c.hostname, out) + } + if !strings.Contains(out, c.want) { + t.Fatalf("SELFPOST_HOSTNAME=%q: output does not mention %q:\n%s", c.hostname, c.want, out) + } + }) + } + + t.Run("valid_hostname_starts", func(t *testing.T) { + out, ok := runEntrypointBackground(t, "mail.example.test") + if !ok { + t.Fatalf("container with a valid SELFPOST_HOSTNAME failed to start:\n%s", out) + } + }) +} + +// runEntrypointBackground starts the container detached and confirms +// supervisord came up (rather than crash-looping) within a short timeout, then +// removes it — the positive control for the gate cases above: a valid +// hostname must not be caught by the same checks. +func runEntrypointBackground(t *testing.T, hostnameEnv string) (output string, started bool) { + t.Helper() + dataDir := t.TempDir() + 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() + + up := exec.Command("docker", "run", "-d", "--name", name, + "-e", "SELFPOST_HOSTNAME="+hostnameEnv, + "-v", dataDir+":/data", + "-v", certDir+":/etc/postfix/tls:ro", + "selfpost:e2e") + if out, err := up.CombinedOutput(); err != nil { + return string(out), false + } + + err := waitFor("supervisord to report RUNNING processes", 20*time.Second, 300*time.Millisecond, func() (bool, error) { + out, err := exec.Command("docker", "exec", name, "supervisorctl", "-c", "/etc/supervisor/supervisord.conf", "status").CombinedOutput() + if strings.Contains(string(out), "RUNNING") { + return true, nil + } + return false, err + }) + logs, _ := exec.Command("docker", "logs", name).CombinedOutput() + return string(logs), err == nil +} diff --git a/test/e2e/mail_helpers.go b/test/e2e/mail_helpers.go new file mode 100644 index 0000000..2ac9ba8 --- /dev/null +++ b/test/e2e/mail_helpers.go @@ -0,0 +1,86 @@ +package e2e + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "regexp" + "time" + + "github.com/emersion/go-msgauth/dkim" +) + +// findSinkMessage polls the sink-MX's dump directory (see test/e2e/sink) for +// a file whose contents contain token — the unique Subject-line marker every +// test send carries — and returns it. Polling a directory listing rather than +// sleeping a fixed duration is what keeps this deterministic even under a +// slow CI runner (plan C.4). +func findSinkMessage(stageDir, token string, timeout time.Duration) ([]byte, error) { + dir := filepath.Join(stageDir, "mail-stage") + var found []byte + err := waitFor(fmt.Sprintf("sink-MX to receive a message tagged %q", token), timeout, 300*time.Millisecond, func() (bool, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return false, err + } + for _, e := range entries { + if e.IsDir() { + continue + } + b, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + continue + } + if bytes.Contains(b, []byte(token)) { + found = b + return true, nil + } + } + return false, fmt.Errorf("no dumped message contains %q yet (%d on disk)", token, len(entries)) + }) + return found, err +} + +// verifyDKIM checks that raw (a message dumped by the sink) carries a DKIM +// signature for domain that validates against the public key published in the +// fake zone — i.e. exactly what a real receiver would check, using DNS the +// harness itself controls instead of net.DefaultResolver (plan C.4). +func verifyDKIM(raw []byte, domain string) error { + verifications, err := dkim.VerifyWithOptions(bytes.NewReader(raw), &dkim.VerifyOptions{ + LookupTXT: lookupTXTFunc(), + }) + if err != nil { + return fmt.Errorf("dkim verify: %w", err) + } + for _, v := range verifications { + if v.Domain == domain { + if v.Err != nil { + return fmt.Errorf("dkim signature for %s did not validate: %w", domain, v.Err) + } + return nil + } + } + return fmt.Errorf("no DKIM signature found for domain %s (got %d signature(s))", domain, len(verifications)) +} + +// connectFromPattern matches Postfix's smtpd "connect from ...[ADDR]" log +// line, which is how the harness learns the source address Postfix itself +// observed for a just-made connection (needed to configure a level-2 rate +// limit's allowed-IPs list — the address a host-published port is seen as +// inside the container depends on Docker's NAT and isn't worth hard-coding). +var connectFromPattern = regexp.MustCompile(`connect from [^\[]*\[([0-9a-fA-F.:]+)\]`) + +// lastSMTPClientIP reads mail.log inside the selfpost container and returns +// the most recent address Postfix's smtpd logged a connection from. +func lastSMTPClientIP(s *stack) (string, error) { + out, err := s.execIn("selfpost", "tail", "-n", "200", "/var/log/mail.log") + if err != nil { + return "", err + } + matches := connectFromPattern.FindAllStringSubmatch(out, -1) + if len(matches) == 0 { + return "", fmt.Errorf("no \"connect from\" line in mail.log yet") + } + return matches[len(matches)-1][1], nil +} diff --git a/test/e2e/main_test.go b/test/e2e/main_test.go new file mode 100644 index 0000000..a445a3a --- /dev/null +++ b/test/e2e/main_test.go @@ -0,0 +1,236 @@ +// 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 +// 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. +package e2e + +import ( + "fmt" + "os" + "regexp" + "strings" + "testing" + "time" +) + +var statusCellPattern = regexp.MustCompile(`(queued|sent|deferred|bounced|rejected)`) + +// h is the single shared stand for the whole ordered scenario in TestE2E. +// TestHostnameGate does not use it — it spins its own disposable container. +var h *stack + +func TestMain(m *testing.M) { + s, err := newStack() + if err != nil { + fmt.Fprintln(os.Stderr, "e2e: build stack:", err) + os.Exit(1) + } + h = s + + if err := prepareStage(s); err != nil { + fmt.Fprintln(os.Stderr, "e2e: prepare stage:", err) + os.Exit(1) + } + if err := s.build(); err != nil { + fmt.Fprintln(os.Stderr, "e2e: build:", err) + os.Exit(1) + } + if err := s.up(); err != nil { + fmt.Fprintln(os.Stderr, "e2e: up:", err) + s.down() + os.Exit(1) + } + + code := m.Run() + + if code != 0 { + for _, svc := range []string{"selfpost", "coredns", "sink"} { + fmt.Fprintf(os.Stderr, "\n==== logs: %s ====\n%s\n", svc, s.logs(svc)) + } + } + s.down() + os.Exit(code) +} + +// senderDomain and recipient are fixed for the whole run: one sending domain +// registered in the panel, and the sink-MX as the single recipient every +// positive send targets. +const ( + senderDomain = "sender.e2e.test" + recipient = "rcpt@sink.e2e.test" +) + +// scenario carries state forward between the ordered subtests of TestE2E: +// each later step needs something an earlier one produced (the domain id, the +// DKIM record, the application credentials, an observed client IP). +type scenario struct { + panel *panelClient + + domainID string + dkimName string + dkimValue string + dkimZoneRelName string // dkimName without the trailing ".e2e.test." + appLogin string + appPassword string + l2AppLogin string + l2AppPassword string + clientIP string + zoneRecords []txtRecord +} + +func TestE2E(t *testing.T) { + sc := &scenario{} + + t.Run("startup_processes_running", func(t *testing.T) { + if err := checkSupervisorProcesses(h); err != nil { + t.Fatal(err) + } + }) + + t.Run("setup_and_login", func(t *testing.T) { + token, err := readSetupToken(h.stageDir) + if err != nil { + t.Fatalf("read setup token: %v", err) + } + p, err := newPanelClient() + if err != nil { + t.Fatal(err) + } + if err := p.setup(token, "e2eadmin", "correct-horse-battery-staple"); err != nil { + t.Fatalf("setup: %v", err) + } + if err := p.login("e2eadmin", "correct-horse-battery-staple"); err != nil { + t.Fatalf("login: %v", err) + } + sc.panel = p + }) + + 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) + } + sc.domainID = id + + name, value, err := sc.panel.dkimRecord(id) + if err != nil { + t.Fatalf("read dkim record: %v", err) + } + sc.dkimName, sc.dkimValue = name, value + + relName, ok := trimZoneSuffix(name) + if !ok { + t.Fatalf("dkim record name %q is not under e2e.test", name) + } + sc.dkimZoneRelName = relName + + records, err := publishTXT(h.stageDir, sc.zoneRecords, txtRecord{name: relName, value: value}) + if err != nil { + t.Fatalf("publish dkim record to fake zone: %v", err) + } + sc.zoneRecords = records + }) + + 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) + } + sc.appLogin, sc.appPassword = login, password + }) + + t.Run("send_verify_dkim_and_status", func(t *testing.T) { + token := uniqueToken("positive") + res := attemptSend(sendAttempt{ + authLogin: sc.appLogin, authPassword: sc.appPassword, + from: "alerts@" + senderDomain, to: recipient, + subject: "e2e " + token, body: "hello from selfpost e2e", + }) + if !res.ok() { + t.Fatalf("positive-path send failed: dial=%v auth=%v mail=%v rcpt=%v data=%v", + res.dialErr, res.authErr, res.mailErr, res.rcptErr, res.dataErr) + } + + raw, err := findSinkMessage(h.stageDir, token, 30*time.Second) + if err != nil { + t.Fatalf("message never reached the sink: %v", err) + } + if err := verifyDKIM(raw, senderDomain); err != nil { + t.Fatalf("DKIM did not verify against the record the panel published: %v", err) + } + + if err := waitFor("send-log row to reach status=sent", 30*time.Second, 500*time.Millisecond, func() (bool, error) { + rows, err := sc.panel.sendLogRows(senderDomain) + if err != nil { + return false, err + } + if containsCell(rows, "sent") { + return true, nil + } + return false, fmt.Errorf("send log not yet at status=sent (still %s)", firstStatusCell(rows)) + }); err != nil { + t.Fatal(err) + } + }) + + t.Run("negative_level2_ratelimit_via_panel", func(t *testing.T) { + testLevel2RateLimit(t, sc) + }) + t.Run("negative_sender_login_mismatch", func(t *testing.T) { + testSenderLoginMismatch(t, sc) + }) + t.Run("negative_no_auth_rejected", func(t *testing.T) { + testNoAuthRejected(t, sc) + }) + t.Run("negative_foreign_relay_rejected", func(t *testing.T) { + testForeignRelayRejected(t, sc) + }) + t.Run("negative_journal_milter_fail_open", func(t *testing.T) { + testJournalMilterFailOpen(t, sc) + }) + t.Run("session_survives_restart", func(t *testing.T) { + testSessionSurvivesRestart(t, sc) + }) + t.Run("negative_level1_ratelimit", func(t *testing.T) { + testLevel1RateLimit(t, sc) + }) +} + +// trimZoneSuffix strips the "e2e.test" suffix a fully-qualified DKIM record +// name carries, returning the name relative to the zone db.zone's $ORIGIN +// expects (see dns_zone.go). +func trimZoneSuffix(fqdn string) (string, bool) { + const suffix = ".e2e.test" + if len(fqdn) > len(suffix) && fqdn[len(fqdn)-len(suffix):] == suffix { + return fqdn[:len(fqdn)-len(suffix)], true + } + return "", false +} + +var tokenCounter int + +// uniqueToken returns a short, human-readable, monotonically distinct marker +// safe to embed in a Subject header and grep for in the sink's dump +// directory. +func uniqueToken(label string) string { + tokenCounter++ + return fmt.Sprintf("%s-%d-%d", label, time.Now().UnixNano(), tokenCounter) +} + +func containsCell(html, needle string) bool { + return strings.Contains(html, ""+needle+"") +} + +func firstStatusCell(html string) string { + m := statusCellPattern.FindStringSubmatch(html) + if m == nil { + return "(no rows yet)" + } + return m[1] +} diff --git a/test/e2e/negative_test.go b/test/e2e/negative_test.go new file mode 100644 index 0000000..c828be9 --- /dev/null +++ b/test/e2e/negative_test.go @@ -0,0 +1,224 @@ +package e2e + +import ( + "fmt" + "strings" + "testing" + "time" +) + +// testLevel2RateLimit is plan C.4 negative check 5: a limit set through the +// panel (not the environment) rejects the message that exceeds it, and the +// rejection is visible in the send log — the panel -> DB -> milter path. +func testLevel2RateLimit(t *testing.T, sc *scenario) { + ip, err := lastSMTPClientIP(h) + if err != nil { + t.Fatalf("determine observed SMTP client IP: %v", err) + } + sc.clientIP = ip + + login, password, err := sc.panel.addApplication(sc.domainID, "l2app", "wildcard", "") + if err != nil { + t.Fatalf("add application: %v", err) + } + sc.l2AppLogin, sc.l2AppPassword = login, password + + appID, err := sc.panel.applicationID(sc.domainID, login) + if err != nil { + t.Fatal(err) + } + if err := sc.panel.setRateLimit(fmt.Sprintf("/applications/%s/ratelimit", appID), ip, 1, 3600); err != nil { + t.Fatalf("save application rate limit: %v", err) + } + + from := "billing@" + senderDomain + + first := attemptSend(sendAttempt{ + authLogin: login, authPassword: password, + from: from, to: recipient, subject: "e2e " + uniqueToken("l2-first"), body: "ok", + }) + if !first.ok() { + t.Fatalf("first message under the level-2 limit was rejected: %v", first.firstErr()) + } + + second := attemptSend(sendAttempt{ + authLogin: login, authPassword: password, + from: from, to: recipient, subject: "e2e " + uniqueToken("l2-second"), body: "should be rejected", + }) + if second.ok() { + t.Fatal("second message exceeding the level-2 limit was accepted, want rejected") + } + if second.mailErr == nil { + t.Fatalf("expected the level-2 rejection at MAIL FROM, got: dial=%v auth=%v rcpt=%v data=%v", + second.dialErr, second.authErr, second.rcptErr, second.dataErr) + } + + if err := waitFor("a rejected row for l2app in the send log", 15*time.Second, 500*time.Millisecond, func() (bool, error) { + rows, err := sc.panel.sendLogRows(senderDomain) + if err != nil { + return false, err + } + if strings.Contains(rows, "l2app") && containsCell(rows, "rejected") { + return true, nil + } + return false, fmt.Errorf("no rejected row for l2app yet") + }); err != nil { + t.Fatal(err) + } +} + +// testSenderLoginMismatch is plan C.4 negative check 2: an authenticated +// application cannot send as a sender it does not own (spec 5.1 p.3, +// reject_sender_login_mismatch) — the core anti-spoofing control. +func testSenderLoginMismatch(t *testing.T, sc *scenario) { + res := attemptSend(sendAttempt{ + authLogin: sc.appLogin, authPassword: sc.appPassword, + from: "someone@not-" + senderDomain, to: recipient, + subject: "e2e " + uniqueToken("mismatch"), body: "should be rejected", + }) + if res.ok() { + t.Fatal("send with a sender the application does not own was accepted, want rejected") + } + // Postfix evaluates smtpd_sender_restrictions with smtpd_delay_reject=yes + // (the default): the mismatch is detected at MAIL FROM but the reject is + // only sent back at RCPT TO, so the error can land on either call here. + if res.mailErr == nil && res.rcptErr == nil { + t.Fatalf("expected the mismatch rejected at MAIL or RCPT, got: dial=%v auth=%v data=%v", + res.dialErr, res.authErr, res.dataErr) + } +} + +// testNoAuthRejected is plan C.4 negative check 1: no SASL session, no mail. +// The sender address used here belongs to no registered domain, so the +// rejection is unambiguously about the missing AUTH and not sender ownership. +func testNoAuthRejected(t *testing.T, sc *scenario) { + res := attemptSend(sendAttempt{ + from: "anyone@unregistered.e2e.test", to: recipient, + subject: "e2e " + uniqueToken("noauth"), body: "should be rejected", + }) + if res.ok() { + t.Fatal("unauthenticated send was accepted, want rejected") + } +} + +// testForeignRelayRejected is plan C.4 negative check 3: a direct proof that +// this is not an open relay — even the exact recipient the positive path just +// delivered to is refused without AUTH (reject_unauth_destination). +func testForeignRelayRejected(t *testing.T, sc *scenario) { + res := attemptSend(sendAttempt{ + from: "anyone@unregistered.e2e.test", to: recipient, + subject: "e2e " + uniqueToken("relay"), body: "should be rejected", + }) + if res.ok() { + t.Fatal("unauthenticated relay to an external destination was accepted, want reject_unauth_destination") + } + if res.rcptErr == nil && res.mailErr == nil { + t.Fatalf("expected rejection at MAIL or RCPT, got: dial=%v auth=%v data=%v", res.dialErr, res.authErr, res.dataErr) + } +} + +// testJournalMilterFailOpen is plan C.4 negative check 6: the journal-milter +// is monitoring-only and fails open (spec 7.3) — stopping the panel process +// (which owns the milter socket) must not block mail, and must not crash the +// container (crashexit only fires on PROCESS_STATE_FATAL, a clean supervisor +// stop is STOPPED, see build/crashexit.py). +func testJournalMilterFailOpen(t *testing.T, sc *scenario) { + if _, err := h.execIn("selfpost", "supervisorctl", "-c", "/etc/supervisor/supervisord.conf", "stop", "panel"); err != nil { + t.Fatalf("stop panel: %v", err) + } + t.Cleanup(func() { + _, _ = h.execIn("selfpost", "supervisorctl", "-c", "/etc/supervisor/supervisord.conf", "start", "panel") + }) + + res := attemptSend(sendAttempt{ + authLogin: sc.appLogin, authPassword: sc.appPassword, + from: "alerts@" + senderDomain, to: recipient, + subject: "e2e " + uniqueToken("failopen"), body: "must still be accepted", + }) + if !res.ok() { + t.Fatalf("send with the journal-milter down was rejected, want fail-open accept: dial=%v auth=%v mail=%v rcpt=%v data=%v", + res.dialErr, res.authErr, res.mailErr, res.rcptErr, res.dataErr) + } + + if err := checkContainerAlive(h); err != nil { + t.Fatalf("container did not survive the panel stopping: %v", err) + } + + // Restart the panel (also undone by t.Cleanup, redundantly and harmlessly, + // in case a later step needs it sooner than cleanup order guarantees). + if _, err := h.execIn("selfpost", "supervisorctl", "-c", "/etc/supervisor/supervisord.conf", "start", "panel"); err != nil { + t.Fatalf("restart panel: %v", err) + } + if err := waitFor("panel HTTP to answer again", 15*time.Second, 300*time.Millisecond, func() (bool, error) { + _, err := sc.panel.status() + return err == nil, err + }); err != nil { + t.Fatal(err) + } +} + +// checkContainerAlive confirms the container is still up after a component +// stop — the crashexit listener must not have brought it down. +func checkContainerAlive(s *stack) error { + out, err := s.execIn("selfpost", "true") + if err != nil { + return fmt.Errorf("container not responding to exec: %v (%s)", err, out) + } + return nil +} + +// testSessionSurvivesRestart is plan C.4 negative/regression check 8 (moved +// here from the manual B.1 stand check, plan item C.4's closing note): the +// login session (SQLite-backed, plan B.1) must survive `docker restart`. +func testSessionSurvivesRestart(t *testing.T, sc *scenario) { + if err := h.restartSelfpost(); err != nil { + t.Fatalf("restart selfpost: %v", err) + } + if err := waitFor("panel HTTP to answer after restart", 30*time.Second, 500*time.Millisecond, func() (bool, error) { + _, err := sc.panel.status() + return err == nil, err + }); err != nil { + t.Fatal(err) + } + // The panel and Postfix come up independently after a restart (see + // waitForSMTPSReady) — the next subtest sends mail, so make sure smtpd is + // actually listening before this one returns. + if err := waitForSMTPSReady(30 * time.Second); err != nil { + t.Fatal(err) + } + resp, err := sc.panel.status() + if err != nil { + t.Fatalf("GET /status after restart: %v", err) + } + if resp.Request.URL.Path != "/status" { + t.Fatalf("session did not survive restart: landed on %s instead of /status", resp.Request.URL.Path) + } +} + +// testLevel1RateLimit is plan C.4 negative check 4: the native Postfix anvil +// backstop (smtpd_client_message_rate_limit, spec 5 p.5), set by the override +// to RATE_LIMIT_MESSAGES_PER_IP=50, rejects once exceeded. It uses a dedicated +// application with no level-2 limit of its own, and retries well past that +// count, so the result is unambiguous regardless of how much of the shared +// per-IP budget earlier subtests already spent (they stay well under 50 +// between them). +func testLevel1RateLimit(t *testing.T, sc *scenario) { + login, password, err := sc.panel.addApplication(sc.domainID, "l1app", "wildcard", "") + if err != nil { + t.Fatalf("add application: %v", err) + } + + const maxAttempts = 60 + for i := 0; i < maxAttempts; i++ { + res := attemptSend(sendAttempt{ + authLogin: login, authPassword: password, + from: "l1@" + senderDomain, to: recipient, + subject: "e2e " + uniqueToken("l1"), body: "rate limit probe", + }) + if !res.ok() { + t.Logf("level-1 limit tripped on attempt %d/%d: %v", i+1, maxAttempts, res.firstErr()) + return + } + } + t.Fatalf("level-1 rate limit (RATE_LIMIT_MESSAGES_PER_IP=5) never tripped after %d sends", maxAttempts) +} diff --git a/test/e2e/panel_client.go b/test/e2e/panel_client.go new file mode 100644 index 0000000..8c38d29 --- /dev/null +++ b/test/e2e/panel_client.go @@ -0,0 +1,234 @@ +package e2e + +import ( + "fmt" + "html" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +// panelBaseURL is where compose.override.yml publishes the panel's plain-HTTP +// port (PANEL_COOKIE_SECURE=false — no reverse proxy in this stand). +const panelBaseURL = "http://127.0.0.1:20080" + +// panelClient drives the panel exactly as a browser would: an HTTP client +// with a cookie jar, HTML forms posted as the templates render them, and +// responses scraped for the bits a later step needs (a domain's id, an +// application's one-shot password, the DKIM record to publish). This is +// deliberate — it is also the only way to prove that what the panel tells an +// administrator to publish is the record that actually verifies (plan C.4). +type panelClient struct { + http *http.Client +} + +func newPanelClient() (*panelClient, error) { + jar, err := cookiejar.New(nil) + if err != nil { + return nil, err + } + return &panelClient{http: &http.Client{ + Jar: jar, + Timeout: 15 * time.Second, + }}, 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")) + if err != nil { + return false, err + } + raw = b + return len(b) > 0, nil + }) + if err != nil { + return "", err + } + u, err := url.Parse(strings.TrimSpace(string(raw))) + if err != nil { + return "", fmt.Errorf("parse setup token file: %w", err) + } + return strings.TrimPrefix(u.Path, "/setup/"), nil +} + +func (c *panelClient) postForm(path string, form url.Values) (*http.Response, string, error) { + resp, err := c.http.PostForm(panelBaseURL+path, form) + if err != nil { + return nil, "", err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + return resp, string(body), err +} + +func (c *panelClient) get(path string) (*http.Response, string, error) { + resp, err := c.http.Get(panelBaseURL + path) + if err != nil { + return nil, "", err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + return resp, string(body), err +} + +// setup creates the administrator through the one-time /setup/ form. +func (c *panelClient) setup(token, username, password string) error { + resp, body, err := c.postForm("/setup/"+token, url.Values{ + "username": {username}, + "password": {password}, + "password_confirm": {password}, + }) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("setup: status %d: %s", resp.StatusCode, body) + } + return nil +} + +// login authenticates and stores the session cookie in the client's jar. +func (c *panelClient) login(username, password string) error { + resp, body, err := c.postForm("/login", url.Values{ + "username": {username}, + "password": {password}, + }) + if err != nil { + return err + } + if resp.Request.URL.Path != "/status" { + return fmt.Errorf("login did not land on /status (landed on %s): %s", resp.Request.URL.Path, body) + } + return nil +} + +// addDomain submits the add-domain form and returns its assigned id, read +// back from the redirect target /domains/{id}. +func (c *panelClient) addDomain(name string) (string, error) { + resp, body, err := c.postForm("/domains", url.Values{"name": {name}}) + if err != nil { + return "", err + } + m := regexp.MustCompile(`^/domains/(\d+)$`).FindStringSubmatch(resp.Request.URL.Path) + if m == nil { + return "", fmt.Errorf("add domain %q: unexpected landing page %s: %s", name, resp.Request.URL.Path, body) + } + return m[1], nil +} + +// dkimRecord fetches a domain's page and scrapes the DKIM DNS record it tells +// the administrator to publish. +func (c *panelClient) dkimRecord(domainID string) (name, value string, err error) { + _, body, err := c.get("/domains/" + domainID) + if err != nil { + return "", "", err + } + name, ok := extractCodeRow(body, "Host / name") + if !ok { + return "", "", fmt.Errorf("could not find DKIM record name on domain page") + } + value, ok = extractCodeRow(body, "Value") + if !ok { + return "", "", fmt.Errorf("could not find DKIM record value on domain page") + } + return name, value, nil +} + +// addApplication submits the add-application form and returns the one-shot +// login/password the panel renders inline (spec 7.6.1 — never recoverable +// later, so this is the only place to read it). +func (c *panelClient) addApplication(domainID, login, mode, addresses string) (appLogin, password string, err error) { + resp, body, err := c.postForm("/domains/"+domainID+"/applications", url.Values{ + "login": {login}, + "mode": {mode}, + "addresses": {addresses}, + }) + if err != nil { + return "", "", err + } + if resp.StatusCode != http.StatusCreated { + return "", "", fmt.Errorf("add application %q: status %d: %s", login, resp.StatusCode, body) + } + appLogin, ok := extractCodeRow(body, "Login") + if !ok { + return "", "", fmt.Errorf("could not find new application login in response") + } + password, ok = extractCodeRow(body, "Password") + if !ok { + return "", "", fmt.Errorf("could not find new application password in response") + } + return appLogin, password, nil +} + +// setRateLimit saves a level-2 differentiated limit (spec 7.4) on either a +// domain (/domains/{id}/ratelimit) or an application (/applications/{id}/ratelimit). +func (c *panelClient) setRateLimit(path, allowedIP string, maxMessages, windowSeconds int) error { + resp, body, err := c.postForm(path, url.Values{ + "allowed_ips": {allowedIP}, + "max_messages": {fmt.Sprintf("%d", maxMessages)}, + "window_seconds": {fmt.Sprintf("%d", windowSeconds)}, + }) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("set rate limit: status %d: %s", resp.StatusCode, body) + } + return nil +} + +// sendLogRows returns the raw /sendlog/rows HTML fragment, filtered to one +// domain, for polling a row's status without parsing full HTML into structs. +func (c *panelClient) sendLogRows(domain string) (string, error) { + _, body, err := c.get("/sendlog/rows?domain=" + url.QueryEscape(domain)) + return body, err +} + +// status fetches the authenticated landing page — used after a container +// restart to confirm the session cookie is still accepted (plan C.4 check 8). +func (c *panelClient) status() (*http.Response, error) { + resp, _, err := c.get("/status") + return resp, err +} + +// applicationID scrapes an application's numeric id off its domain page row, +// keyed by login — needed to build /applications/{id}/ratelimit, which the +// add-application response (just the login/password) does not carry. +func (c *panelClient) applicationID(domainID, login string) (string, error) { + _, body, err := c.get("/domains/" + domainID) + if err != nil { + return "", err + } + pattern := `(?s)` + regexp.QuoteMeta(login) + `.*?/applications/(\d+)/mode` + m := regexp.MustCompile(pattern).FindStringSubmatch(body) + if m == nil { + return "", fmt.Errorf("could not find application id for login %q", login) + } + return m[1], nil +} + +// extractCodeRow scrapes the value of a " ... VALUE" pair from a rendered panel page (see +// internal/web/templates/domain_detail.html). It is deliberately anchored to +// the label text rather than position, so it survives unrelated template +// reordering. html/template's escaper is conservative about which characters +// it entity-encodes in text nodes — a DKIM value's base64 "+" comes back as +// "+" — so the match is HTML-unescaped before returning. +func extractCodeRow(body, label string) (string, bool) { + pattern := `\s*
\s*([^<]*)` + m := regexp.MustCompile(pattern).FindStringSubmatch(body) + if m == nil { + return "", false + } + return html.UnescapeString(strings.TrimSpace(m[1])), true +} diff --git a/test/e2e/process_check.go b/test/e2e/process_check.go new file mode 100644 index 0000000..4b8ff05 --- /dev/null +++ b/test/e2e/process_check.go @@ -0,0 +1,61 @@ +package e2e + +import ( + "fmt" + "strings" + "time" +) + +// wantRunning are the supervisord programs that must be RUNNING once the +// container is up (build/supervisord.conf). postfix-reload is deliberately +// excluded: autostart=false, its healthy resting state is STOPPED. +var wantRunning = []string{"opendkim", "panel", "postfix", "cert-reload", "logrotate"} + +// checkSupervisorProcesses shells into the container directly (not through the +// panel's /status page) so it works before an administrator account even +// exists — this is the first thing the harness checks after `docker compose +// up` (plan C.4). Programs take a moment to leave STARTING right after the +// container starts, so this polls rather than checking once. +func checkSupervisorProcesses(s *stack) error { + var lastErr error + err := waitFor("all supervised programs to reach their steady state", 20*time.Second, 500*time.Millisecond, func() (bool, error) { + out, execErr := s.execIn("selfpost", "supervisorctl", "-c", "/etc/supervisor/supervisord.conf", "status") + // supervisorctl exits non-zero when any program is not RUNNING, which + // is expected for postfix-reload — parse the output regardless of exit + // status. + states := parseSupervisorStatus(out) + if len(states) == 0 { + lastErr = fmt.Errorf("supervisorctl status produced nothing to parse: %v\n%s", execErr, out) + return false, lastErr + } + for _, name := range wantRunning { + state, ok := states[name] + if !ok { + lastErr = fmt.Errorf("program %q not reported by supervisorctl:\n%s", name, out) + return false, lastErr + } + if state != "RUNNING" { + lastErr = fmt.Errorf("program %q is %s, want RUNNING:\n%s", name, state, out) + return false, lastErr + } + } + if state, ok := states["postfix-reload"]; ok && state != "STOPPED" { + lastErr = fmt.Errorf("program postfix-reload is %s, want STOPPED (autostart=false):\n%s", state, out) + return false, lastErr + } + return true, nil + }) + return err +} + +func parseSupervisorStatus(out string) map[string]string { + states := make(map[string]string) + for _, line := range strings.Split(out, "\n") { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + states[fields[0]] = fields[1] + } + return states +} diff --git a/test/e2e/sink/Dockerfile b/test/e2e/sink/Dockerfile new file mode 100644 index 0000000..016f405 --- /dev/null +++ b/test/e2e/sink/Dockerfile @@ -0,0 +1,26 @@ +# SMTP sink-MX for the e2e stand (plan C.4): accepts any mail on 25 and dumps +# each transaction to its own file under /mail, so the test harness can read +# the raw received message (headers + body, including the DKIM-Signature +# Postfix/OpenDKIM added) straight off disk. smtp-sink ships in the postfix +# package itself — no new dependency. +FROM debian:bookworm-slim + +ENV DEBIAN_FRONTEND=noninteractive +RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \ + && echo "postfix postfix/main_mailer_type string Internet Site" | debconf-set-selections \ + && apt-get update \ + && apt-get install -y --no-install-recommends postfix \ + && rm -rf /var/lib/apt/lists/* + +RUN mkdir -p /mail +WORKDIR /mail + +# -d: one file per transaction (a pseudo-random suffix is appended +# automatically), so the harness just watches the directory for a new file +# instead of parsing a shared multi-message dump. Binding :25 needs root, and +# smtp-sink refuses to run as root without an explicit -u to switch to after +# binding — "-u root" satisfies that check while staying root throughout, +# which is fine here: throwaway container, isolated e2e network, never +# exposed. +ENTRYPOINT ["/usr/sbin/smtp-sink"] +CMD ["-u", "root", "-d", "/mail/%Y%m%d%H%M%S.", "0.0.0.0:25", "100"] diff --git a/test/e2e/smtp_client.go b/test/e2e/smtp_client.go new file mode 100644 index 0000000..1fe805a --- /dev/null +++ b/test/e2e/smtp_client.go @@ -0,0 +1,142 @@ +package e2e + +import ( + "crypto/tls" + "fmt" + "net" + "net/smtp" + "time" +) + +// smtpsAddr is where compose.override.yml publishes the primary implicit-TLS +// submission port (465 in the shipped compose, remapped here to stay off the +// dev server's real ports). +const smtpsAddr = "127.0.0.1:20465" + +// sendAttempt is one SMTP transaction against the stand. An empty authLogin +// means "connect without AUTH" (negative checks 1/3); from/to are full +// mailbox addresses. +type sendAttempt struct { + authLogin, authPassword string + from, to string + subject, body string +} + +// sendResult records the error (if any) at each stage the transaction +// reached. Only the fields up to and including the first failure are set — +// the rest are nil because that stage was never attempted, not because it +// succeeded silently. +type sendResult struct { + dialErr error + authErr error + mailErr error + rcptErr error + dataErr error +} + +// ok reports whether the message was fully accepted (DATA closed cleanly). +func (r sendResult) ok() bool { + return r.dialErr == nil && r.authErr == nil && r.mailErr == nil && r.rcptErr == nil && r.dataErr == nil +} + +// firstErr returns whichever stage failed first, or nil if none did. +func (r sendResult) firstErr() error { + for _, e := range []error{r.dialErr, r.authErr, r.mailErr, r.rcptErr, r.dataErr} { + if e != nil { + return e + } + } + return nil +} + +// attemptSend drives one SMTP transaction over the implicit-TLS port, exactly +// as an application client library would: TLS connect, EHLO, optional AUTH +// PLAIN, MAIL/RCPT/DATA. The certificate is the stand's throwaway self-signed +// one (see stage.go), so verification is skipped — Postfix's +// smtpd_tls_security_level is "may" here just like production, this is purely +// about the test client trusting an unknown CA. +func attemptSend(a sendAttempt) sendResult { + var res sendResult + + dialer := &net.Dialer{Timeout: 10 * time.Second} + conn, err := tls.DialWithDialer(dialer, "tcp", smtpsAddr, &tls.Config{ + InsecureSkipVerify: true, + ServerName: selfpostHostname, + }) + if err != nil { + res.dialErr = fmt.Errorf("dial %s: %w", smtpsAddr, err) + return res + } + c, err := smtp.NewClient(conn, selfpostHostname) + if err != nil { + res.dialErr = fmt.Errorf("smtp handshake: %w", err) + _ = conn.Close() + return res + } + defer c.Close() + + if err := c.Hello("e2e-client.e2e.test"); err != nil { + res.dialErr = err + return res + } + + if a.authLogin != "" { + auth := smtp.PlainAuth("", a.authLogin, a.authPassword, selfpostHostname) + if err := c.Auth(auth); err != nil { + res.authErr = err + _ = c.Quit() + return res + } + } + + if err := c.Mail(a.from); err != nil { + res.mailErr = err + _ = c.Quit() + return res + } + if err := c.Rcpt(a.to); err != nil { + res.rcptErr = err + _ = c.Quit() + return res + } + w, err := c.Data() + if err != nil { + res.dataErr = err + _ = c.Quit() + return res + } + msg := buildMessage(a) + if _, err := w.Write([]byte(msg)); err != nil { + res.dataErr = err + } else if err := w.Close(); err != nil { + res.dataErr = err + } + _ = c.Quit() + return res +} + +// waitForSMTPSReady polls the implicit-TLS port until a bare TCP connect +// succeeds. Used after a container (re)start: the panel's HTTP port and +// Postfix's smtpd come up independently (postfix-wrapper.sh additionally +// waits on both milter sockets before starting Postfix at all), so a caller +// that only waited for the panel could still race a not-yet-listening smtpd. +func waitForSMTPSReady(timeout time.Duration) error { + return waitFor("smtps port to accept connections", timeout, 300*time.Millisecond, func() (bool, error) { + conn, err := net.DialTimeout("tcp", smtpsAddr, 2*time.Second) + if err != nil { + return false, err + } + _ = conn.Close() + return true, nil + }) +} + +// buildMessage renders a minimal, valid RFC 5322 message. subject is expected +// to carry a unique token so the harness can find this exact message in the +// sink-MX's dump directory afterwards. +func buildMessage(a sendAttempt) string { + return fmt.Sprintf( + "From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n%s\r\n", + a.from, a.to, a.subject, time.Now().UTC().Format(time.RFC1123Z), a.body, + ) +} diff --git a/test/e2e/stack.go b/test/e2e/stack.go new file mode 100644 index 0000000..f39bb06 --- /dev/null +++ b/test/e2e/stack.go @@ -0,0 +1,113 @@ +package e2e + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" +) + +// project isolates this stand's containers/network from anything else on the +// host (in particular a prod SelfPost stack in an unrelated compose project — +// plan C.4). stageDir holds everything a single e2e run writes: the generated +// TLS cert, the /data bind mount, the DNS zone CoreDNS serves, and the mail +// sink's dump directory. It is never reused across runs. +const project = "selfpost-e2e" + +// stack drives the compose lifecycle. baseCompose is the shipped +// deploy/docker-compose.yml — deliberately the real one, not a parallel test +// compose, so cap_drop/cap_add/no-new-privileges are exercised as documented +// rather than a laxer stand-in (plan C.4). +type stack struct { + repoRoot string + baseComposeFile string + overrideFile string + stageDir string +} + +func newStack() (*stack, error) { + wd, err := os.Getwd() // test/e2e, where go.mod lives + if err != nil { + return nil, err + } + repoRoot, err := filepath.Abs(filepath.Join(wd, "..", "..")) + if err != nil { + return nil, err + } + stageDir, err := filepath.Abs(filepath.Join(wd, ".stage")) + if err != nil { + return nil, err + } + return &stack{ + repoRoot: repoRoot, + baseComposeFile: filepath.Join(repoRoot, "deploy", "docker-compose.yml"), + overrideFile: filepath.Join(wd, "compose.override.yml"), + stageDir: stageDir, + }, nil +} + +// compose runs `docker compose ` against this stand's two files and +// project directory/name, streaming output to the test log on failure. +func (s *stack) compose(args ...string) ([]byte, error) { + full := append([]string{ + "compose", + "-p", project, + "-f", s.baseComposeFile, + "-f", s.overrideFile, + "--project-directory", s.stageDir, + }, args...) + cmd := exec.Command("docker", full...) + // deploy/docker-compose.yml interpolates ${SELFPOST_HOSTNAME:?...} at + // compose-file parse time, straight from the process environment — that + // happens before the override file's `environment:` mapping is even + // looked at (that mapping only reaches the container, not compose's own + // variable interpolation), so it has to be set here too. + cmd.Env = append(os.Environ(), "SELFPOST_HOSTNAME="+selfpostHostname) + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + err := cmd.Run() + if err != nil { + return out.Bytes(), fmt.Errorf("docker %v: %w\n%s", full, err, out.String()) + } + return out.Bytes(), nil +} + +// execIn runs a command inside the running service container via `docker +// compose exec -T`, the same way the harness checks supervisord/mail.log +// state without going through the panel (so those checks work even before an +// administrator account exists). +func (s *stack) execIn(service string, args ...string) (string, error) { + full := append([]string{"exec", "-T", service}, args...) + out, err := s.compose(full...) + return string(out), err +} + +func (s *stack) build() error { + _, err := s.compose("build") + return err +} + +func (s *stack) up() error { + _, err := s.compose("up", "-d", "--force-recreate") + return err +} + +// restartSelfpost restarts just the selfpost container the way `docker +// restart` would (plan C.4 check 8: a session must survive that), without +// tearing down the DNS/sink sidecars or their state. +func (s *stack) restartSelfpost() error { + _, err := s.compose("restart", "selfpost") + return err +} + +func (s *stack) down() { + _, _ = s.compose("down", "-v", "--remove-orphans") +} + +// 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) + return string(out) +} diff --git a/test/e2e/stage.go b/test/e2e/stage.go new file mode 100644 index 0000000..c036a75 --- /dev/null +++ b/test/e2e/stage.go @@ -0,0 +1,86 @@ +package e2e + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "os" + "path/filepath" + "time" +) + +// selfpostHostname is SELFPOST_HOSTNAME for the whole e2e stand: the +// certificate's CN/SAN, the panel's SASL realm and Postfix's myhostname/HELO +// all have to agree on it (plan B.3), so it is defined once here. +const selfpostHostname = "mail.e2e.test" + +// prepareStage (re)creates the scratch directory compose.override.yml mounts +// everything from: /data, the TLS cert Postfix serves on 465, the DNS zone +// 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 { + return fmt.Errorf("clean stage dir: %w", err) + } + dirs := []string{"data", "certs", "dns-stage", "mail-stage"} + for _, d := range dirs { + if err := os.MkdirAll(filepath.Join(s.stageDir, d), 0o755); err != nil { + return fmt.Errorf("mkdir %s: %w", d, err) + } + } + if err := writeSelfSignedCert( + filepath.Join(s.stageDir, "certs", "fullchain.pem"), + filepath.Join(s.stageDir, "certs", "privkey.pem"), + ); err != nil { + return err + } + corefile, err := os.ReadFile(filepath.Join("dns", "Corefile")) + if err != nil { + return fmt.Errorf("read Corefile: %w", err) + } + if err := os.WriteFile(filepath.Join(s.stageDir, "dns-stage", "Corefile"), corefile, 0o644); err != nil { + return fmt.Errorf("write Corefile: %w", err) + } + return writeZone(s.stageDir, nil) +} + +// 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, +// so the e2e SMTP client simply skips verification of it (plan C.4: "test +// dependencies... zero new dependencies" — no need for a real CA here). +func writeSelfSignedCert(certPath, keyPath string) error { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return fmt.Errorf("generate TLS key: %w", err) + } + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return err + } + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: selfpostHostname}, + DNSNames: []string{selfpostHostname}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: true, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + return fmt.Errorf("create certificate: %w", err) + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + if err := os.WriteFile(certPath, certPEM, 0o644); err != nil { + return err + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) + return os.WriteFile(keyPath, keyPEM, 0o600) +} diff --git a/test/e2e/wait.go b/test/e2e/wait.go new file mode 100644 index 0000000..22c890e --- /dev/null +++ b/test/e2e/wait.go @@ -0,0 +1,29 @@ +package e2e + +import ( + "fmt" + "time" +) + +// waitFor polls check every interval until it returns true or timeout elapses. +// Every wait in this suite goes through here — no fixed sleeps standing in for +// a readiness check (plan C.4): a passing check ends the wait immediately, and +// a timeout fails with what was being waited for, not a bare "timed out". +func waitFor(what string, timeout, interval time.Duration, check func() (bool, error)) error { + deadline := time.Now().Add(timeout) + var lastErr error + for { + ok, err := check() + if ok { + return nil + } + lastErr = err + if time.Now().After(deadline) { + if lastErr != nil { + return fmt.Errorf("timed out after %s waiting for %s: %w", timeout, what, lastErr) + } + return fmt.Errorf("timed out after %s waiting for %s", timeout, what) + } + time.Sleep(interval) + } +}