Publish Alpine musl distroless Postfix as the sole latest image
Build and Push Docker Image / build (push) Successful in 1m25s
Build and Push Docker Image / build (push) Successful in 1m25s
This commit is contained in:
1 parent
2a9a7c6baa
commit
7ead00647b
12 files changed
+776
-1274
No files matched your search
+276
@@ -0,0 +1,276 @@
|
||||
// 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")
|
||||
}
|
||||
Reference in new issue
Block a user