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 <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -32,6 +32,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
|
|
||||||
### Fixed
|
### 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
|
- 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
|
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).
|
and mask the real error (release CI on both amd64 and arm64).
|
||||||
|
|||||||
+57
-47
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,6 +77,9 @@ 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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user