Files
selfpost/test/e2e/stack.go
T
mixandClaude Opus 5.5 faa3c11ae7
test / test (push) Canceled after 0s
panel-dkim-signing stage 2: OpenDKIM removed
The panel's signer is the only one. Gone: the opendkim package and its
configuration, the syslog-relay and opendkim programs, /dev/log and
/run/opendkim, the reload by USR1 through supervisord
(internal/supervisor/supervisor.go), internal/syslogrelay, the port of
OpenDKIM's From: parser (internal/milter/mailparse.go and its probe,
harness and test/upstream scripts), OPENDKIM_SOCKET and OPENDKIM_DIR.

Keys: internal/domain/keys.go (Keys, NewKeys, DKIM_DIR=/data/dkim) keeps
the keys and writes a selector table, /data/dkim/selectors ("<name>
<selector>"), which the signer reads in place of the KeyTable - it reads
no database, as the sender check reads none. The table is written before
the signer serves (EnsureServerSigned moved there from the notifier).
Keys and table 0600, directories 0700, the panel's. The entrypoint copies
/data/opendkim/keys once, as the panel user, into /data/dkim.new renamed
when whole, and stops the start if the copy fails; /data/opendkim is left
for a rollback to 2.7.x.

Sender check: one RFC 5322 address as a mail program reads it, held
against the bindings; the 256-byte refusal and the texts naming OpenDKIM
go. Allowed() folded the domain through strings.ToLower, which makes the
Kelvin sign a "k" - the comparison with OpenDKIM's reading had hidden it;
it folds ASCII only now.

Health: two dkim-signer rows (applications, notifications) in Milter
sockets, opendkim out of the liveness set; the card no longer takes any
row with a note for the spam filter. Texts as in the design commit.

KILL stays in deploy/docker-compose.yml: measured on the test server,
root without it cannot signal a process of another uid - supervisord
stopping the panel, Postfix's master its children. The plan's "only use
was the reload" was wrong; the comment now gives the real uses.

e2e: one signature, the panel's, verified by dkimpy; the /data/dkim modes;
the OpenDKIM log and process checks, testNeverSignedForAnother and the
cited opendkim, libopendkim11 and libmilter1.0.1 go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 01:05:39 +03:00

146 lines
5.0 KiB
Go

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
// env is added to compose's own environment. The shipped compose file takes
// the optional features from there (${INBOUND_RELAY_ENABLE:-false} and the
// like), so this is how a later part of the run switches them on.
env []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, from the shell's environment or the project
// directory's .env, the shell first (docs.docker.com/compose/how-tos/
// environment-variables/variable-interpolation/; assumptions C-K9) - the
// override file's `environment:` mapping only reaches the container, not
// compose's own interpolation - so it is set here, where it wins.
cmd.Env = append(os.Environ(), "SELFPOST_HOSTNAME="+selfpostHostname)
cmd.Env = append(cmd.Env, s.env...)
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
}
// expectedVersion is the version the stand's image is built with: the
// release's X.Y.Z when release.yml runs the suite, "e2e" for a local run. Its
// default must stay the default of VERSION in compose.override.yml.
func expectedVersion() string {
if v := os.Getenv("SELFPOST_E2E_VERSION"); v != "" {
return v
}
return "e2e"
}
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
}
// recreateSelfpost replaces the selfpost container with one started from the
// same /data and the environment as it is now (see env), leaving the DNS and
// sink sidecars and their state alone.
func (s *stack) recreateSelfpost() error {
_, err := s.compose("up", "-d", "--force-recreate", "--no-deps", "selfpost")
return err
}
func (s *stack) down() {
_, _ = 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, dkim 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.
func (s *stack) logs(service string) string {
out, _ := s.compose("logs", "--no-color", service)
return string(out)
}