Files

495 lines
12 KiB
Go

// SPDX-License-Identifier: GPL-3.0-or-later
// mailsink is an SMTP server for test and development networks. It accepts
// mail for an allowlist of domains and throws it away: message bodies are
// read off the socket and discarded, never buffered or written to disk, and
// the program has no code that opens an outbound connection, so nothing it
// receives can be relayed. It writes no logs.
//
// All configuration is compiled in at build time (see the Dockerfile); the
// running binary reads no environment variables and no files.
package main
import (
"bufio"
"errors"
"flag"
"fmt"
"net"
"net/netip"
"os"
"strconv"
"strings"
"time"
)
// Set by the Dockerfile through -ldflags -X.
var (
allowedDomains = ""
allowedNetworks = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7"
hostname = "mailsink.test"
port = "25"
maxMessageSize = "26214400"
maxRecipients = "100"
maxConnections = "100"
)
const (
commandTimeout = 5 * time.Minute
dataTimeout = 10 * time.Minute
maxLineLength = 4096
maxErrors = 10
)
type config struct {
anyDomain bool
domains map[string]bool
suffixes []string
networks []netip.Prefix
hostname string
addr string
maxMessageSize int64
maxRecipients int
maxConnections int
}
func splitList(s string) []string {
var out []string
for _, f := range strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' || r == '\n' }) {
out = append(out, strings.ToLower(strings.TrimSpace(f)))
}
return out
}
func positive(name, v string) (int64, error) {
n, err := strconv.ParseInt(v, 10, 64)
if err != nil || n <= 0 {
return 0, fmt.Errorf("%s must be a positive integer, got %q", name, v)
}
return n, nil
}
func loadConfig() (*config, error) {
c := &config{domains: map[string]bool{}, hostname: hostname}
for _, d := range splitList(allowedDomains) {
d = strings.TrimSuffix(d, ".")
switch {
case d == "*":
c.anyDomain = true
case strings.HasPrefix(d, "*."):
c.suffixes = append(c.suffixes, d[1:])
case d != "":
c.domains[d] = true
}
}
if !c.anyDomain && len(c.domains) == 0 && len(c.suffixes) == 0 {
return nil, errors.New("ALLOWED_DOMAINS is empty")
}
// Loopback is always allowed so the container healthcheck works.
c.networks = []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8"), netip.MustParsePrefix("::1/128")}
for _, n := range splitList(allowedNetworks) {
p, err := netip.ParsePrefix(n)
if err != nil {
return nil, fmt.Errorf("ALLOWED_NETWORKS: %v", err)
}
c.networks = append(c.networks, p.Masked())
}
p, err := positive("SMTP_PORT", port)
if err != nil || p > 65535 {
return nil, fmt.Errorf("SMTP_PORT must be 1-65535, got %q", port)
}
c.addr = ":" + port
if c.maxMessageSize, err = positive("MAX_MESSAGE_SIZE", maxMessageSize); err != nil {
return nil, err
}
n, err := positive("MAX_RECIPIENTS", maxRecipients)
if err != nil {
return nil, err
}
c.maxRecipients = int(n)
if n, err = positive("MAX_CONNECTIONS", maxConnections); err != nil {
return nil, err
}
c.maxConnections = int(n)
if c.hostname == "" {
return nil, errors.New("SMTP_HOSTNAME is empty")
}
return c, nil
}
func (c *config) clientAllowed(remote net.Addr) bool {
ap, err := netip.ParseAddrPort(remote.String())
if err != nil {
return false
}
a := ap.Addr().Unmap()
for _, p := range c.networks {
if p.Contains(a) {
return true
}
}
return false
}
func (c *config) domainAllowed(d string) bool {
d = strings.TrimSuffix(strings.ToLower(d), ".")
if c.anyDomain || c.domains[d] {
return true
}
for _, s := range c.suffixes {
if strings.HasSuffix(d, s) {
return true
}
}
return false
}
func main() {
check := flag.Bool("check", false, "validate the compiled-in configuration, print it and exit")
health := flag.Bool("healthcheck", false, "connect to the local server and exit 0 if it answers")
flag.Parse()
cfg, err := loadConfig()
if err != nil {
fmt.Fprintln(os.Stderr, "mailsink: configuration:", err)
os.Exit(2)
}
switch {
case *check:
fmt.Printf("hostname: %s\nlisten: %s\nallowed domains: %s\nallowed networks: %s\nmax message size: %d\nmax recipients: %d\nmax connections: %d\n",
cfg.hostname, cfg.addr, allowedDomains, allowedNetworks, cfg.maxMessageSize, cfg.maxRecipients, cfg.maxConnections)
return
case *health:
os.Exit(healthcheck(cfg))
}
ln, err := net.Listen("tcp", cfg.addr)
if err != nil {
fmt.Fprintln(os.Stderr, "mailsink:", err)
os.Exit(1)
}
slots := make(chan struct{}, cfg.maxConnections)
for {
conn, err := ln.Accept()
if err != nil {
time.Sleep(50 * time.Millisecond)
continue
}
select {
case slots <- struct{}{}:
go func() {
defer func() { <-slots }()
serve(cfg, conn)
}()
default:
conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
conn.Write([]byte("421 4.3.2 Too many connections, try again later\r\n"))
conn.Close()
}
}
}
func healthcheck(cfg *config) int {
conn, err := net.DialTimeout("tcp", "127.0.0.1"+cfg.addr, 3*time.Second)
if err != nil {
return 1
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(3 * time.Second))
line, err := bufio.NewReader(conn).ReadString('\n')
if err != nil || !strings.HasPrefix(line, "220") {
return 1
}
conn.Write([]byte("QUIT\r\n"))
return 0
}
type session struct {
cfg *config
conn net.Conn
r *bufio.Reader
w *bufio.Writer
greeted bool
mailFrom bool
recipients int
errors int
}
func serve(cfg *config, conn net.Conn) {
defer conn.Close()
s := &session{cfg: cfg, conn: conn, r: bufio.NewReaderSize(conn, maxLineLength), w: bufio.NewWriter(conn)}
if !cfg.clientAllowed(conn.RemoteAddr()) {
s.reply("554 5.7.1 Access denied")
return
}
s.reply("220 " + cfg.hostname + " ESMTP mailsink; all mail is discarded")
for {
line, tooLong, err := s.readLine(commandTimeout)
if err != nil {
return
}
if tooLong {
if !s.fail("500 5.5.2 Line too long") {
return
}
continue
}
verb, arg, _ := strings.Cut(line, " ")
arg = strings.TrimSpace(arg)
switch strings.ToUpper(verb) {
case "EHLO":
if arg == "" {
s.fail("501 5.5.4 EHLO requires a domain")
continue
}
s.reset()
s.greeted = true
s.reply("250-"+s.cfg.hostname,
"250-PIPELINING",
"250-SIZE "+strconv.FormatInt(s.cfg.maxMessageSize, 10),
"250-8BITMIME",
"250-ENHANCEDSTATUSCODES",
"250-AUTH PLAIN LOGIN",
"250 SMTPUTF8")
case "HELO":
if arg == "" {
s.fail("501 5.5.4 HELO requires a domain")
continue
}
s.reset()
s.greeted = true
s.reply("250 " + s.cfg.hostname)
case "AUTH":
if !s.auth(arg) {
return
}
case "MAIL":
s.mail(arg)
case "RCPT":
s.rcpt(arg)
case "DATA":
if !s.data() {
return
}
case "RSET":
s.reset()
s.reply("250 2.0.0 OK")
case "NOOP":
s.reply("250 2.0.0 OK")
case "VRFY":
s.reply("252 2.1.5 Cannot verify user")
case "HELP":
s.reply("214 2.0.0 mailsink accepts mail for its allowed domains and discards it")
case "QUIT":
s.reply("221 2.0.0 Bye")
return
case "STARTTLS", "BDAT", "ETRN", "EXPN", "TURN":
if !s.fail("502 5.5.1 Command not implemented") {
return
}
default:
if !s.fail("500 5.5.2 Command not recognized") {
return
}
}
}
}
func (s *session) reply(lines ...string) {
s.conn.SetWriteDeadline(time.Now().Add(commandTimeout))
for _, l := range lines {
s.w.WriteString(l)
s.w.WriteString("\r\n")
}
s.w.Flush()
}
// fail sends an error reply and returns false once the client has made too
// many errors, after telling it the connection is being closed.
func (s *session) fail(msg string) bool {
s.reply(msg)
s.errors++
if s.errors >= maxErrors {
s.reply("421 4.7.0 Too many errors, closing connection")
return false
}
return true
}
func (s *session) reset() {
s.mailFrom = false
s.recipients = 0
}
// readLine returns one line without its line ending. A line longer than the
// buffer is drained and reported as tooLong.
func (s *session) readLine(timeout time.Duration) (line string, tooLong bool, err error) {
for {
s.conn.SetReadDeadline(time.Now().Add(timeout))
chunk, rerr := s.r.ReadSlice('\n')
if errors.Is(rerr, bufio.ErrBufferFull) {
tooLong = true
continue
}
if rerr != nil {
return "", false, rerr
}
if tooLong {
return "", true, nil
}
return strings.TrimRight(string(chunk), "\r\n"), false, nil
}
}
// parsePath extracts the address from "FROM:<addr> PARAMS" or "TO:<addr>",
// returning the address and whatever parameters follow it.
func parsePath(arg, prefix string) (addr, params string, ok bool) {
if len(arg) < len(prefix) || !strings.EqualFold(arg[:len(prefix)], prefix) {
return "", "", false
}
rest := strings.TrimSpace(arg[len(prefix):])
if !strings.HasPrefix(rest, "<") {
return "", "", false
}
end := strings.IndexByte(rest, '>')
if end < 0 {
return "", "", false
}
addr = rest[1:end]
// Drop an obsolete source route: <@a,@b:[email protected]>
if strings.HasPrefix(addr, "@") {
if i := strings.IndexByte(addr, ':'); i >= 0 {
addr = addr[i+1:]
}
}
return addr, strings.TrimSpace(rest[end+1:]), true
}
func (s *session) auth(arg string) bool {
// Any credentials are accepted so applications configured with a
// username and password work unchanged. They are never looked at.
mech, initial, _ := strings.Cut(arg, " ")
var prompts []string
switch strings.ToUpper(mech) {
case "PLAIN":
if initial == "" {
prompts = []string{"334 "}
}
case "LOGIN":
prompts = []string{"334 VXNlcm5hbWU6", "334 UGFzc3dvcmQ6"}
if initial != "" {
prompts = prompts[1:]
}
default:
return s.fail("504 5.5.4 Unrecognized authentication mechanism")
}
for _, p := range prompts {
s.reply(p)
line, _, err := s.readLine(commandTimeout)
if err != nil {
return false
}
if line == "*" {
return s.fail("501 5.0.0 Authentication cancelled")
}
}
s.reply("235 2.7.0 Authentication successful")
return true
}
func (s *session) mail(arg string) {
if !s.greeted {
s.fail("503 5.5.1 Send EHLO or HELO first")
return
}
if s.mailFrom {
s.fail("503 5.5.1 Sender already given")
return
}
_, params, ok := parsePath(arg, "FROM:")
if !ok {
s.fail("501 5.5.4 Syntax: MAIL FROM:<address>")
return
}
for _, p := range strings.Fields(params) {
k, v, _ := strings.Cut(p, "=")
if strings.EqualFold(k, "SIZE") {
if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > s.cfg.maxMessageSize {
s.reply("552 5.3.4 Message size exceeds fixed limit")
return
}
}
}
s.mailFrom = true
s.reply("250 2.1.0 OK")
}
func (s *session) rcpt(arg string) {
if !s.mailFrom {
s.fail("503 5.5.1 Need MAIL before RCPT")
return
}
addr, _, ok := parsePath(arg, "TO:")
if !ok || addr == "" {
s.fail("501 5.5.4 Syntax: RCPT TO:<address>")
return
}
if s.recipients >= s.cfg.maxRecipients {
s.reply("452 4.5.3 Too many recipients")
return
}
at := strings.LastIndexByte(addr, '@')
if at < 0 {
// RFC 5321 requires accepting mail for a bare postmaster.
if !strings.EqualFold(addr, "postmaster") {
s.fail("501 5.1.3 Recipient address must include a domain")
return
}
} else if !s.cfg.domainAllowed(addr[at+1:]) {
s.fail("550 5.7.1 Relaying denied: domain not accepted here")
return
}
s.recipients++
s.reply("250 2.1.5 OK")
}
// data reads the message body and discards it as it arrives. It returns
// false if the connection should be closed.
func (s *session) data() bool {
if s.recipients == 0 {
return s.fail("503 5.5.1 Need RCPT before DATA")
}
s.reply("354 End data with <CR><LF>.<CR><LF>")
var size int64
atLineStart := true
for {
s.conn.SetReadDeadline(time.Now().Add(dataTimeout))
chunk, err := s.r.ReadSlice('\n')
complete := err == nil
if err != nil && !errors.Is(err, bufio.ErrBufferFull) {
return false
}
if atLineStart && complete && (string(chunk) == ".\r\n" || string(chunk) == ".\n") {
break
}
size += int64(len(chunk))
atLineStart = complete
}
s.reset()
if size > s.cfg.maxMessageSize {
s.reply("552 5.3.4 Message size exceeds fixed limit")
return true
}
s.reply("250 2.0.0 OK: message accepted and discarded")
return true
}