Files
docker-postfix/tests/smoke.go
T
Ketan Patel 7ead00647b
Build and Push Docker Image / build (push) Successful in 1m25s
Publish Alpine musl distroless Postfix as the sole latest image
2026-10-03 00:17:09 -04:00

277 lines
8.3 KiB
Go

// Runs in the exact scratch filesystem during the image build. No external mail.
package main
import (
"bufio"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/pem"
"fmt"
"math/big"
"net"
"net/smtp"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
)
func must(err error) {
if err != nil {
panic(err)
}
}
func command(args ...string) []byte {
c := exec.Command(args[0], args[1:]...)
out, err := c.CombinedOutput()
if err != nil {
panic(fmt.Sprintf("%v: %v\n%s", args, err, out))
}
return out
}
func certificate() tls.Certificate {
key, err := rsa.GenerateKey(rand.Reader, 2048)
must(err)
template := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "localhost"},
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
must(err)
cert, err := tls.X509KeyPair(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
must(err)
return cert
}
func serve(conn net.Conn, cert tls.Certificate, mechanism string, delivered chan<- string) {
defer conn.Close()
must(conn.SetDeadline(time.Now().Add(25 * time.Second)))
reader := bufio.NewReader(conn)
secured, authenticated := false, false
send := func(s string) { _, err := fmt.Fprint(conn, s+"\r\n"); must(err) }
send("220 localhost test relay")
for {
line, err := reader.ReadString('\n')
if err != nil {
return
}
line = strings.TrimSpace(line)
fields := strings.Fields(line)
if len(fields) == 0 {
continue
}
switch strings.ToUpper(fields[0]) {
case "EHLO":
if secured {
send("250-localhost\r\n250 AUTH " + mechanism)
} else {
send("250-localhost\r\n250 STARTTLS")
}
case "STARTTLS":
send("220 Ready for TLS")
secure := tls.Server(conn, &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12})
must(secure.Handshake())
conn = secure
reader = bufio.NewReader(conn)
secured = true
case "AUTH":
if !secured || len(fields) < 2 {
send("535 TLS required")
continue
}
decode := func(encoded string) string {
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded))
must(err)
return string(decoded)
}
if fields[1] == "LOGIN" {
encoded := ""
if len(fields) > 2 {
encoded = fields[2]
} else {
send("334 VXNlcm5hbWU6")
encoded, err = reader.ReadString('\n')
must(err)
}
if decode(encoded) != "test-user" {
panic("incorrect SASL username")
}
send("334 UGFzc3dvcmQ6")
encoded, err = reader.ReadString('\n')
must(err)
if decode(encoded) != "test-password" {
panic("incorrect SASL password")
}
} else if fields[1] == "PLAIN" {
encoded := ""
if len(fields) > 2 {
encoded = fields[2]
} else {
send("334 ")
encoded, err = reader.ReadString('\n')
must(err)
}
if decode(encoded) != "\x00test-user\x00test-password" {
panic("incorrect SASL credentials")
}
} else {
panic("unexpected SASL mechanism: " + fields[1])
}
authenticated = true
send("235 Authentication successful")
case "MAIL", "RCPT":
if !authenticated {
send("530 Authentication required")
} else {
send("250 OK")
}
case "DATA":
if !authenticated {
send("530 Authentication required")
continue
}
send("354 End with dot")
var body strings.Builder
for {
part, err := reader.ReadString('\n')
must(err)
if part == ".\r\n" {
break
}
body.WriteString(part)
}
send("250 Queued")
delivered <- body.String()
case "QUIT":
send("221 Bye")
return
case "RSET", "NOOP":
send("250 OK")
default:
panic("unexpected SMTP command: " + line)
}
}
}
func start() *exec.Cmd {
cmd := exec.Command("/usr/local/bin/postfix-entrypoint")
cmd.Env = append(os.Environ(), "SMTP_SERVER=127.0.0.1", "SMTP_PORT=2525", "SMTP_USERNAME=test-user", "SMTP_PASSWORD=test-password",
"SERVER_HOSTNAME=relay.example.test", "DOMAIN=example.test", "LOCAL_NETWORK=127.0.0.0/8", "SMTP_HEADER_TAG=ci-test")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
must(cmd.Start())
deadline := time.Now().Add(20 * time.Second)
for time.Now().Before(deadline) {
if exec.Command("/usr/local/bin/postfix-entrypoint", "--healthcheck").Run() == nil {
return cmd
}
time.Sleep(200 * time.Millisecond)
}
panic("Postfix did not become healthy")
}
func stop(cmd *exec.Cmd) {
must(cmd.Process.Signal(syscall.SIGTERM))
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case <-done:
case <-time.After(10 * time.Second):
cmd.Process.Kill()
panic("Postfix did not stop on SIGTERM")
}
}
func main() {
// Guarantee a bounded build even if a daemon misbehaves.
go func() { time.Sleep(80 * time.Second); panic("smoke test timeout") }()
for _, path := range []string{"/bin/sh", "/bin/bash", "/bin/busybox", "/sbin/apk", "/usr/bin/python3", "/usr/bin/go"} {
if _, err := os.Stat(path); !os.IsNotExist(err) {
panic("unexpected runtime tool: " + path)
}
}
missing := exec.Command("/usr/local/bin/postfix-entrypoint")
missing.Env = []string{"PATH=/usr/sbin:/usr/bin:/bin"}
if missing.Run() == nil {
panic("missing credentials accepted")
}
listener, err := net.Listen("tcp4", "127.0.0.1:2525")
must(err)
defer listener.Close()
delivered := make(chan string, 4)
cert := certificate()
go func() {
count := 0
for {
conn, err := listener.Accept()
if err != nil {
return
}
mechanism := "PLAIN"
if count%2 == 1 {
mechanism = "LOGIN"
}
count++
go serve(conn, cert, mechanism, delivered)
}
}()
// An empty queue volume, including stale PID contents, must initialize safely.
must(os.MkdirAll("/var/spool/postfix/pid", 0755))
must(os.WriteFile("/var/spool/postfix/pid/master.pid", []byte("999999\n"), 0644))
// Reproduce the deprecated setting from the previous image.
command("/usr/sbin/postconf", "-e", "smtp_use_tls = yes")
cmd := start()
if strings.Contains(string(command("/usr/sbin/postconf", "-n")), "smtp_use_tls") {
panic("deprecated smtp_use_tls setting survived migration")
}
for _, path := range []string{"/etc/postfix/sasl_passwd", "/etc/postfix/sasl_passwd.lmdb"} {
info, err := os.Stat(path)
must(err)
if info.Mode().Perm() != 0600 {
panic("unsafe credential mode")
}
}
command("/usr/local/bin/postfix-entrypoint", "--check")
// Ensure dynamically loaded PCRE and LMDB maps both work.
must(os.WriteFile("/tmp/test.pcre", []byte("/^test$/ OK\n"), 0644))
if strings.TrimSpace(string(command("/usr/sbin/postmap", "-q", "test", "pcre:/tmp/test.pcre"))) != "OK" {
panic("PCRE plugin failed")
}
for round := 0; round < 2; round++ {
message := "From: sender@example.test\r\nTo: recipient@example.net\r\nSubject: distroless smoke\r\nMIME-Version: 1.0\r\n\r\nLocal smoke message\r\n"
must(smtp.SendMail("127.0.0.1:25", nil, "sender@example.test", []string{"recipient@example.net"}, []byte(message)))
select {
case body := <-delivered:
if !strings.Contains(body, "Local smoke message") || !strings.Contains(body, "RelayTag: ci-test") {
panic("message/header missing")
}
case <-time.After(25 * time.Second):
panic("SASL/TLS relay delivery timed out")
}
// Wait for qmgr to remove the successfully delivered message.
for i := 0; i < 30; i++ {
if strings.Contains(string(command("/usr/sbin/postqueue", "-p")), "Mail queue is empty") {
break
}
time.Sleep(100 * time.Millisecond)
}
if !strings.Contains(string(command("/usr/sbin/postqueue", "-p")), "Mail queue is empty") {
panic("queue did not drain")
}
stop(cmd)
if round == 0 {
cmd = start()
}
}
if exec.Command("/usr/local/bin/postfix-entrypoint", "--healthcheck").Run() == nil {
panic("healthcheck succeeded after shutdown")
}
// No source or test binary is copied into the final runtime.
must(os.MkdirAll(filepath.Dir("/tmp/smoke-passed"), 0755))
must(os.WriteFile("/tmp/smoke-passed", []byte("startup, healthcheck, empty queue, restart, SMTP, STARTTLS, SASL, LMDB, PCRE, header tag, SIGTERM: passed\n"), 0644))
fmt.Println("Distroless Postfix smoke tests passed")
}