From 231d341d0d2b0c5e45be1c76f7552e351f02bb6d Mon Sep 17 00:00:00 2001 From: mixeme Date: Sun, 9 Aug 2026 01:22:50 +0300 Subject: [PATCH] fix(entrypoint): make /data traversable; hostname gate first (v1.0.0) Go TempDir bind mounts arrive as 0700; after chown panel:panel, OpenDKIM could not reach KeyTable and the container crash-looped. chmod 755 /data, check SELFPOST_HOSTNAME before /data setup, and harden the e2e stand (restart: no, readiness logs). Co-Authored-By: Composer Co-authored-by: Cursor --- CHANGELOG.md | 6 ++ build/entrypoint.sh | 104 ++++++++++++++++++--------------- test/e2e/compose.override.yml | 4 ++ test/e2e/hostname_gate_test.go | 8 +++ test/e2e/process_check.go | 5 +- 5 files changed, 79 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e917e1a..1c423c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ### Fixed +- 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). diff --git a/build/entrypoint.sh b/build/entrypoint.sh index b0087a5..c24179f 100644 --- a/build/entrypoint.sh +++ b/build/entrypoint.sh @@ -1,14 +1,69 @@ #!/bin/sh # Container entrypoint (runs as root, PID 1 until it execs supervisord). # +# SELFPOST_HOSTNAME is checked first (plan B.3): it must fail fast with a clear +# message before /data normalisation or Postfix config, so a bad identity never +# looks like a permissions or packaging problem (and so the e2e hostname-gate +# tests see the FATAL text rather than an earlier set -e abort). +set -e + +# SELFPOST_HOSTNAME is an identity, not a setting with a safe default: it must +# simultaneously match the PTR/rDNS record, the certificate CN/SAN, and the +# Cyrus SASL realm (spec 5.2 p.3, 8). The panel (main.go saslRealm()) and +# postfix-config.sh each fall back independently when it's unset — to +# `localhost` and to the container hostname respectively — so accounts get +# written under one realm and looked up under another and authentication +# silently fails for every application, while HELO also stops matching the +# PTR record and mail that does go out lands in spam. No fallback can be +# correct, so fail loudly here, before either side of that split has a chance +# to run, rather than leave a green panel with broken mail. +if [ -z "$SELFPOST_HOSTNAME" ]; then + cat >&2 <<'EOF' +FATAL: SELFPOST_HOSTNAME is not set. + +This is the mail server's identity: it becomes the Postfix HELO/EHLO name, +the Cyrus SASL realm that application passwords are looked up under, and it +must match the TLS certificate's CN/SAN as well as this server's PTR (reverse +DNS) record. There is no safe default — guessing any one of these wrong +breaks authentication for every application or sends outgoing mail to spam, +silently. + +Set it to the mail server's fully-qualified domain name, e.g.: + + SELFPOST_HOSTNAME=mail.example.com + +in the .env file next to your docker-compose.yml (see deploy/.env.example). +EOF + exit 1 +fi + +case "$SELFPOST_HOSTNAME" in + *[\ \ ]* | *://* | *:* ) + echo "FATAL: SELFPOST_HOSTNAME must be a bare hostname (no scheme, port, or spaces): \"$SELFPOST_HOSTNAME\"" >&2 + echo 'Example: SELFPOST_HOSTNAME=mail.example.com' >&2 + exit 1 + ;; + *.*) + ;; + *) + echo "FATAL: SELFPOST_HOSTNAME must be a fully-qualified domain name (at least one dot): \"$SELFPOST_HOSTNAME\"" >&2 + echo 'Example: SELFPOST_HOSTNAME=mail.example.com' >&2 + exit 1 + ;; +esac + # The persistent root /data is a host bind mount (spec 9), so it arrives owned # by the host user (typically root), not by the unprivileged panel user that # actually writes the SQLite database, setup token and DKIM keys (spec 7.6.8). # Fix its ownership here — the one place still running as root — before handing # off to supervisord, which starts the panel as the panel user. -set -e - +# +# Mode must stay world-traversable (0755): OpenDKIM and Postfix reach their +# trees under /data as other users. Go's testing.TempDir is 0700, and a bare +# chown would leave that mode in place — opendkim then cannot read KeyTable and +# the container crash-loops (e2e TestHostnameGate/valid_hostname_starts). chown panel:panel /data +chmod 755 /data # Restored backups or previously-created state may contain panel-owned files # under /data; make sure they stay writable without disturbing anything that a # later phase deliberately hands to another service. /data/log is exempt: it is @@ -83,51 +138,6 @@ chown opendkim:selfpost /run/opendkim chown panel:selfpost /run/selfpost chmod 2750 /run/opendkim /run/selfpost -# SELFPOST_HOSTNAME is an identity, not a setting with a safe default: it must -# simultaneously match the PTR/rDNS record, the certificate CN/SAN, and the -# Cyrus SASL realm (spec 5.2 p.3, 8). The panel (main.go saslRealm()) and -# postfix-config.sh each fall back independently when it's unset — to -# `localhost` and to the container hostname respectively — so accounts get -# written under one realm and looked up under another and authentication -# silently fails for every application, while HELO also stops matching the -# PTR record and mail that does go out lands in spam. No fallback can be -# correct, so fail loudly here, before either side of that split has a chance -# to run, rather than leave a green panel with broken mail. -if [ -z "$SELFPOST_HOSTNAME" ]; then - cat >&2 <<'EOF' -FATAL: SELFPOST_HOSTNAME is not set. - -This is the mail server's identity: it becomes the Postfix HELO/EHLO name, -the Cyrus SASL realm that application passwords are looked up under, and it -must match the TLS certificate's CN/SAN as well as this server's PTR (reverse -DNS) record. There is no safe default — guessing any one of these wrong -breaks authentication for every application or sends outgoing mail to spam, -silently. - -Set it to the mail server's fully-qualified domain name, e.g.: - - SELFPOST_HOSTNAME=mail.example.com - -in the .env file next to your docker-compose.yml (see deploy/.env.example). -EOF - exit 1 -fi - -case "$SELFPOST_HOSTNAME" in - *[\ \ ]* | *://* | *:* ) - echo "FATAL: SELFPOST_HOSTNAME must be a bare hostname (no scheme, port, or spaces): \"$SELFPOST_HOSTNAME\"" >&2 - echo 'Example: SELFPOST_HOSTNAME=mail.example.com' >&2 - exit 1 - ;; - *.*) - ;; - *) - echo "FATAL: SELFPOST_HOSTNAME must be a fully-qualified domain name (at least one dot): \"$SELFPOST_HOSTNAME\"" >&2 - echo 'Example: SELFPOST_HOSTNAME=mail.example.com' >&2 - exit 1 - ;; -esac - # Generate the outbound-relay Postfix configuration from the environment (spec # 5). Kept out of the image build so cert paths, rate limits, hostname and the # optional 587 service are all driven by env at run time, and re-derived on every diff --git a/test/e2e/compose.override.yml b/test/e2e/compose.override.yml index ee6de56..6b5c38c 100644 --- a/test/e2e/compose.override.yml +++ b/test/e2e/compose.override.yml @@ -16,6 +16,10 @@ services: selfpost: + # Crash-looping with unless-stopped makes `compose exec` fail with + # "Container is restarting" and hides the entrypoint/supervisord exit + # reason; keep the stand exited so logs stay attached to one attempt. + restart: "no" build: # Resolved relative to --project-directory (.stage), NOT this file's own # directory — compose build.context paths follow the project directory, diff --git a/test/e2e/hostname_gate_test.go b/test/e2e/hostname_gate_test.go index f44b0e3..0da2e2c 100644 --- a/test/e2e/hostname_gate_test.go +++ b/test/e2e/hostname_gate_test.go @@ -16,6 +16,11 @@ import ( func runEntrypoint(t *testing.T, hostnameEnv string) (output string, exitedZero bool) { t.Helper() dataDir := t.TempDir() + // Entrypoint also chmod 755 /data; mirror that here so a pre-fix image + // still gets a traversable bind mount under Go's 0700 TempDir. + if err := os.Chmod(dataDir, 0o755); err != nil { + t.Fatalf("chmod data dir: %v", err) + } args := []string{ "run", "--rm", @@ -72,6 +77,9 @@ func TestHostnameGate(t *testing.T) { func runEntrypointBackground(t *testing.T, hostnameEnv string) (output string, started bool) { t.Helper() dataDir := t.TempDir() + if err := os.Chmod(dataDir, 0o755); err != nil { + t.Fatalf("chmod data dir: %v", err) + } certDir := t.TempDir() if err := writeSelfSignedCert(certDir+"/fullchain.pem", certDir+"/privkey.pem"); err != nil { t.Fatalf("generate throwaway TLS cert: %v", err) diff --git a/test/e2e/process_check.go b/test/e2e/process_check.go index 4b8ff05..399a234 100644 --- a/test/e2e/process_check.go +++ b/test/e2e/process_check.go @@ -45,7 +45,10 @@ func checkSupervisorProcesses(s *stack) error { } return true, nil }) - return err + if err != nil { + return fmt.Errorf("%w\n==== selfpost logs ====\n%s", err, s.logs("selfpost")) + } + return nil } func parseSupervisorStatus(out string) map[string]string {