release: cut 1.0.0
test / test (push) Has been cancelled
release / prepare (push) Has been cancelled
release / build (amd64, ubuntu-latest) (push) Has been cancelled
release / build (arm64, ubuntu-24.04-arm) (push) Has been cancelled
release / merge (push) Has been cancelled

Pin compose and local trial to ghcr.io/mixeme/selfpost:1.0.0, close the
CHANGELOG cut, and retire implementation-plan / v1.x-closure-plan.

Includes the post-cut startup fixes needed for a green release e2e gate:
root-owned TLS copies for postfix check, maillog_file_prefixes for /data,
hostname gate and traversable /data, panel /healthz before setup, and
setup-token / TempDir reclaim via docker exec.

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