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:
2026-08-03 00:29:14 +03:00
parent 3eb3e94bce
commit f3bc24b638
21 changed files with 1674 additions and 17 deletions
+71 -15
View File
@@ -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"
+4
View File
@@ -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/
+19
View File
@@ -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
+11 -1
View File
@@ -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 ./...
+2 -1
View File
@@ -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` эскейпит `+` в `&#43;` даже в тексте — скрапер значений со страницы обязан `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/` это делает).
+82
View File
@@ -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
+16
View File
@@ -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
}
+120
View File
@@ -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)
}
}
+7
View File
@@ -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
+4
View File
@@ -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=
+101
View File
@@ -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
}
+86
View File
@@ -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
}
+236
View File
@@ -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]
}
+224
View File
@@ -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)
}
+234
View File
@@ -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
// "&#43;" — 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
}
+61
View File
@@ -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
}
+26
View File
@@ -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"]
+142
View File
@@ -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,
)
}
+113
View File
@@ -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)
}
+86
View File
@@ -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)
}
+29
View File
@@ -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)
}
}