277 lines
8.3 KiB
Go
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")
|
|
}
|