feat: implement C.4 — hermetic container e2e gate + native release build
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 <noreply@anthropic.com>
This commit is contained in:
@@ -6,26 +6,87 @@ name: release
|
|||||||
# into both the image tag and the panel binary's -ldflags version, so the two
|
# 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
|
# can never drift apart (the invariant restore's version check in spec 7.5.A
|
||||||
# depends on).
|
# 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:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v[0-9]+.[0-9]+.[0-9]+"
|
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
prepare:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.version.outputs.version }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Derive version from tag
|
- name: Derive version from tag
|
||||||
id: version
|
id: version
|
||||||
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
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
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Log in to ghcr.io
|
- name: Log in to ghcr.io
|
||||||
@@ -35,14 +96,9 @@ jobs:
|
|||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push
|
- name: Merge per-arch tags into the version manifest
|
||||||
uses: docker/build-push-action@v6
|
run: |
|
||||||
with:
|
docker buildx imagetools create \
|
||||||
context: .
|
-t "ghcr.io/${{ github.repository }}:${{ needs.prepare.outputs.version }}" \
|
||||||
file: build/Dockerfile
|
"ghcr.io/${{ github.repository }}:${{ needs.prepare.outputs.version }}-amd64" \
|
||||||
platforms: linux/amd64,linux/arm64
|
"ghcr.io/${{ github.repository }}:${{ needs.prepare.outputs.version }}-arm64"
|
||||||
build-args: |
|
|
||||||
VERSION=${{ steps.version.outputs.version }}
|
|
||||||
push: true
|
|
||||||
provenance: false
|
|
||||||
tags: ghcr.io/${{ github.repository }}:${{ steps.version.outputs.version }}
|
|
||||||
|
|||||||
@@ -7,3 +7,7 @@ dev/
|
|||||||
|
|
||||||
# Local Go caches / test output, if ever present.
|
# Local Go caches / test output, if ever present.
|
||||||
*.out
|
*.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/
|
||||||
|
|||||||
@@ -5,6 +5,25 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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 +
|
- ops: `mail.log` rotation switched from `copytruncate` to rename +
|
||||||
`postfix reload` (the same mechanism `postfix logrotate` itself uses),
|
`postfix reload` (the same mechanism `postfix logrotate` itself uses),
|
||||||
eliminating the up-to-one-second window in which `copytruncate` could drop
|
eliminating the up-to-one-second window in which `copytruncate` could drop
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ MODULE := codeberg.org/mix/selfpost
|
|||||||
LDFLAGS := -X $(MODULE)/internal/buildinfo.Version=$(VERSION)
|
LDFLAGS := -X $(MODULE)/internal/buildinfo.Version=$(VERSION)
|
||||||
GOFLAGS := -trimpath
|
GOFLAGS := -trimpath
|
||||||
|
|
||||||
.PHONY: all build vet test clean
|
.PHONY: all build vet test clean e2e
|
||||||
|
|
||||||
all: vet build
|
all: vet build
|
||||||
|
|
||||||
@@ -29,3 +29,13 @@ test:
|
|||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf bin
|
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 ./...
|
||||||
|
|||||||
+2
-1
@@ -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.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.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 ./...` чистые.
|
- **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, требует согласования).
|
- **Дальше — то, что перечислено в `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/` это делает).
|
- **Прод:** `selfpost.example.com`, реальный Let's Encrypt сертификат, живой e2e (DKIM/SPF pass). Контейнер там всё ещё на образе v1.0 — Фаза 14 в него не выкатывалась. При апгрейде: админа один раз разлогинит (сменилось имя cookie), а от reverse-proxy требуется передача исходного `Host` (Apache-фрагмент из `deploy/` это делает).
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
// <character-string> 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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(`<td>(queued|sent|deferred|bounced|rejected)</td>`)
|
||||||
|
|
||||||
|
// 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, "<td>"+needle+"</td>")
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstStatusCell(html string) string {
|
||||||
|
m := statusCellPattern.FindStringSubmatch(html)
|
||||||
|
if m == nil {
|
||||||
|
return "(no rows yet)"
|
||||||
|
}
|
||||||
|
return m[1]
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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/<token> 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)<td class="code">` + regexp.QuoteMeta(login) + `</td>.*?/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 "<label>LABEL</label> ... <span
|
||||||
|
// class=\"code\">VALUE</span>" 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 := `<label>` + regexp.QuoteMeta(label) + `</label>\s*<div class="code-row">\s*<span class="code">([^<]*)</span>`
|
||||||
|
m := regexp.MustCompile(pattern).FindStringSubmatch(body)
|
||||||
|
if m == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return html.UnescapeString(strings.TrimSpace(m[1])), true
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 <args...>` 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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user