// 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())) }