Files
docker-postfix/install/postfix-entrypoint.go
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

231 lines
7.5 KiB
Go

// Native configuration and process launcher for the Alpine-derived runtime.
package main
import (
"bufio"
"errors"
"fmt"
"net"
"os"
"os/exec"
"os/user"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
)
func fail(err error) { fmt.Fprintln(os.Stderr, "postfix-entrypoint:", err); os.Exit(1) }
func check(err error) {
if err != nil {
fail(err)
}
}
func env(name, fallback string) string {
value := os.Getenv(name)
if value == "" {
value = fallback
}
if strings.ContainsAny(value, "\r\n\x00") {
fail(fmt.Errorf("%s must be a single line", name))
}
return value
}
func run(command string, args ...string) {
c := exec.Command(command, args...)
c.Stdout = os.Stdout
c.Stderr = os.Stderr
check(c.Run())
}
func health() error {
c, err := net.DialTimeout("tcp4", "127.0.0.1:25", 3*time.Second)
if err != nil {
return err
}
defer c.Close()
if err = c.SetDeadline(time.Now().Add(3 * time.Second)); err != nil {
return err
}
line, err := bufio.NewReader(c).ReadString('\n')
if err != nil {
return err
}
if !strings.HasPrefix(line, "220 ") {
return fmt.Errorf("unexpected SMTP greeting: %s", line)
}
_, err = c.Write([]byte("QUIT\r\n"))
return err
}
func directory(path string, uid, gid int, mode os.FileMode) {
check(os.MkdirAll(path, mode))
info, err := os.Lstat(path)
check(err)
if !info.IsDir() {
fail(fmt.Errorf("%s must be a directory, not a symlink", path))
}
check(os.Chown(path, uid, gid))
check(os.Chmod(path, mode))
}
func initQueue() {
account, err := user.Lookup("postfix")
check(err)
group, err := user.LookupGroup("postdrop")
check(err)
uid, err := strconv.Atoi(account.Uid)
check(err)
gid, err := strconv.Atoi(group.Gid)
check(err)
directory("/var/lib/postfix", uid, 0, 0700)
directory("/var/spool/postfix", 0, 0, 0755)
directory("/var/spool/postfix/pid", 0, 0, 0755)
for _, name := range strings.Fields("active bounce corrupt defer deferred flush hold incoming private saved trace") {
directory("/var/spool/postfix/"+name, uid, 0, 0700)
}
directory("/var/spool/postfix/maildrop", uid, gid, 0730)
directory("/var/spool/postfix/public", uid, gid, 0710)
// Keep the pid file: master locks it and safely handles stale contents itself.
// Unlinking a live lock could permit two masters on a shared queue.
run("/usr/sbin/postsuper")
}
func networks() string {
local := env("LOCAL_NETWORK", "")
if local == "" {
addresses, err := net.InterfaceAddrs()
check(err)
for _, address := range addresses {
n, ok := address.(*net.IPNet)
if ok && n.IP.To4() != nil && !n.IP.IsLoopback() {
local = (&net.IPNet{IP: n.IP.Mask(n.Mask), Mask: n.Mask}).String()
break
}
}
if local == "" {
fail(errors.New("cannot detect local network; set LOCAL_NETWORK"))
}
}
values := []string{"127.0.0.0/8", "[::1]/128"}
for _, item := range append([]string{local}, strings.Split(env("SMTP_NETWORKS", ""), ",")...) {
item = strings.TrimSpace(item)
if item == "" {
continue
}
ip, network, err := net.ParseCIDR(item)
if err != nil || ip.To4() == nil {
fail(fmt.Errorf("invalid IPv4 network: %s", item))
}
values = append(values, network.String())
}
return strings.Join(values, ", ")
}
func configure() {
server := env("SMTP_SERVER", "")
username := env("SMTP_USERNAME", "")
password := env("SMTP_PASSWORD", "")
if server == "" || username == "" || password == "" {
fail(errors.New("SMTP_SERVER, SMTP_USERNAME and SMTP_PASSWORD are required"))
}
if strings.ContainsAny(server, " \t[]") {
fail(errors.New("SMTP_SERVER must be a hostname or IP address"))
}
port := env("SMTP_PORT", "587")
number, err := strconv.Atoi(port)
if err != nil || number < 1 || number > 65535 {
fail(errors.New("SMTP_PORT must be between 1 and 65535"))
}
hostname, err := os.Hostname()
check(err)
hostname = env("SERVER_HOSTNAME", hostname)
domain := env("DOMAIN", "")
if domain == "" {
_, suffix, ok := strings.Cut(hostname, ".")
if ok {
domain = suffix
} else {
domain = "localdomain"
}
}
timezone := env("TIMEZONE", "America/New_York")
if filepath.IsAbs(timezone) || strings.Contains(timezone, "..") {
fail(errors.New("invalid TIMEZONE"))
}
zone, err := os.ReadFile(filepath.Join("/usr/share/zoneinfo", timezone))
check(err)
check(os.WriteFile("/etc/localtime", zone, 0644))
check(os.WriteFile("/etc/timezone", []byte(timezone+"\n"), 0644))
relay := fmt.Sprintf("[%s]:%s", server, port)
config := map[string]string{
"myhostname": hostname, "mydomain": domain, "mydestination": "$myhostname", "myorigin": "$mydomain",
"mynetworks": networks(), "inet_interfaces": "all", "inet_protocols": "ipv4", "relayhost": relay,
"relay_domains": "*", "smtp_sasl_auth_enable": "yes", "smtp_sasl_password_maps": "lmdb:/etc/postfix/sasl_passwd",
"smtp_sasl_security_options": "noanonymous", "smtp_sasl_tls_security_options": "noanonymous",
"smtp_sasl_mechanism_filter": "plain, login", "smtp_tls_security_level": "may",
"smtp_tls_CAfile": "/etc/ssl/certs/ca-certificates.crt",
"header_size_limit": "4096000", "mailbox_size_limit": "0", "message_size_limit": "52428800",
"recipient_delimiter": "+", "smtpd_relay_restrictions": "permit_mynetworks, reject_unauth_destination",
"smtpd_recipient_restrictions": "permit_mynetworks, reject_unauth_destination", "smtpd_sasl_local_domain": domain,
"maillog_file": "/dev/stdout", "maillog_file_prefixes": "/var, /dev", "maximal_queue_lifetime": "1d",
"bounce_queue_lifetime": "1d", "queue_run_delay": "300s", "minimal_backoff_time": "300s", "maximal_backoff_time": "4000s",
}
for _, provider := range []string{"mailtrap.io", "sendgrid", "maileroo"} {
if strings.Contains(strings.ToLower(server), provider) {
config["smtp_tls_security_level"] = "encrypt"
config["smtp_tls_session_cache_database"] = "lmdb:${data_directory}/smtp_scache"
config["smtp_tls_loglevel"] = "1"
}
}
if env("DEBUG", "no") == "yes" {
config["debug_peer_level"] = "2"
}
tag := env("SMTP_HEADER_TAG", "")
if tag != "" {
check(os.WriteFile("/etc/postfix/header_tag", []byte("/^MIME-Version:/i PREPEND RelayTag: "+tag+"\n/^Content-Transfer-Encoding:/i PREPEND RelayTag: "+tag+"\n"), 0644))
config["header_checks"] = "regexp:/etc/postfix/header_tag"
} else {
config["header_checks"] = ""
}
// Remove the obsolete setting even when reusing an existing configuration.
run("/usr/sbin/postconf", "-X", "smtp_use_tls")
// Pass arguments directly; no shell expansion or secret-bearing command line.
args := []string{"-e"}
for key, value := range config {
args = append(args, key+" = "+value)
}
run("/usr/sbin/postconf", args...)
// Restrict both the source and LMDB files, including any pre-existing files.
syscall.Umask(0077)
check(os.WriteFile("/etc/postfix/sasl_passwd", []byte(relay+" "+username+":"+password+"\n"), 0600))
check(os.Chmod("/etc/postfix/sasl_passwd", 0600))
run("/usr/sbin/postmap", "lmdb:/etc/postfix/sasl_passwd")
check(os.Chmod("/etc/postfix/sasl_passwd.lmdb", 0600))
syscall.Umask(0022)
check(os.Unsetenv("SMTP_PASSWORD"))
check(os.Unsetenv("SMTP_USERNAME"))
initQueue()
fmt.Fprintf(os.Stderr, "Postfix configured: hostname=%s relay=%s\n", hostname, relay)
}
func main() {
if len(os.Args) > 1 {
switch os.Args[1] {
case "--healthcheck":
check(health())
return
case "--check":
run("/usr/sbin/postconf", "-n")
run("/usr/sbin/postconf", "-M")
return
default:
path, err := exec.LookPath(os.Args[1])
check(err)
check(syscall.Exec(path, os.Args[1:], os.Environ()))
return
}
}
configure()
mode := "-s"
if os.Getpid() == 1 {
mode = "-i"
}
check(syscall.Exec("/usr/libexec/postfix/master", []string{"master", mode}, os.Environ()))
}