Add mailsink, a discard-only SMTP server for test networks

Accepts mail for an allowlist of domains from allowlisted client networks
and discards it on receipt. No outbound code, no logging, scratch image;
all settings are Dockerfile build arguments compiled into the binary.
This commit is contained in:
2026-09-24 12:36:16 -07:00
commit 92b8688033
7 changed files with 679 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/mailsink
+63
View File
@@ -0,0 +1,63 @@
# syntax=docker/dockerfile:1
# ---------------------------------------------------------------------------
# Configuration
#
# Everything below is compiled into the binary at build time. The running
# container reads no environment variables and no config files, so what is
# set here is what runs. Rebuild the image to change it.
# ---------------------------------------------------------------------------
# Recipient domains mail is accepted for. Mail to any other domain is
# refused with 550 at RCPT TO. Comma-separated.
# example.test that domain only
# *.example.test any subdomain of example.test (not example.test itself)
# * any domain
ARG ALLOWED_DOMAINS="example.test,*.example.test"
# Client addresses allowed to connect, as CIDRs. Anything else gets 554 and
# is disconnected. Loopback is always allowed (for the healthcheck).
# Defaults to the private ranges; narrow this to your test network.
ARG ALLOWED_NETWORKS="10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7"
# Name used in the SMTP greeting and EHLO reply.
ARG SMTP_HOSTNAME="mailsink.test"
# Port to listen on inside the container.
ARG SMTP_PORT="25"
# Largest message accepted, in bytes (default 25 MiB).
ARG MAX_MESSAGE_SIZE="26214400"
# Most recipients per message.
ARG MAX_RECIPIENTS="100"
# Most simultaneous client connections.
ARG MAX_CONNECTIONS="100"
# ---------------------------------------------------------------------------
FROM golang:1.27-alpine AS build
ARG ALLOWED_DOMAINS ALLOWED_NETWORKS SMTP_HOSTNAME SMTP_PORT MAX_MESSAGE_SIZE MAX_RECIPIENTS MAX_CONNECTIONS
WORKDIR /src
COPY go.mod main.go ./
RUN CGO_ENABLED=0 go build -trimpath -o /mailsink -ldflags "-s -w \
-X 'main.allowedDomains=${ALLOWED_DOMAINS}' \
-X 'main.allowedNetworks=${ALLOWED_NETWORKS}' \
-X 'main.hostname=${SMTP_HOSTNAME}' \
-X 'main.port=${SMTP_PORT}' \
-X 'main.maxMessageSize=${MAX_MESSAGE_SIZE}' \
-X 'main.maxRecipients=${MAX_RECIPIENTS}' \
-X 'main.maxConnections=${MAX_CONNECTIONS}'" . \
# Fail the build on a bad setting rather than at container start.
&& /mailsink -check
# The runtime image holds the one static binary and nothing else: no shell,
# no MTA, no mail spool, no network tools.
FROM scratch
ARG SMTP_PORT
COPY --from=build /mailsink /mailsink
USER 65534:65534
EXPOSE ${SMTP_PORT}
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s CMD ["/mailsink", "-healthcheck"]
ENTRYPOINT ["/mailsink"]
+62
View File
@@ -0,0 +1,62 @@
# mailsink
An SMTP server for test and development networks. Applications on other
machines send mail to it as they would to a real server. It accepts mail for
the domains you allow and discards it.
- **Nothing is kept.** Message bodies are read off the socket and discarded
as they arrive. They are never held in memory as a whole or written to
disk, and the container's filesystem is read-only.
- **Nothing is relayed.** The server contains no code that opens an outbound
connection, and the runtime image has only its one static binary: no MTA,
no shell, no network tools. Mail to a domain outside the allowlist gets
`550 5.7.1` at `RCPT TO`.
- **Only local clients.** Connections from addresses outside
`ALLOWED_NETWORKS` get `554` and are closed.
- **No logging.** The server writes nothing while it runs, and the compose
file sets Docker's logging driver to `none`.
## Configure
All settings are build arguments at the top of the `Dockerfile`, with
comments explaining each one. They are compiled into the binary, so the
running container reads no environment variables or config files. To change
a setting, edit the Dockerfile and rebuild. A bad value fails the build.
| Setting | Default |
|---|---|
| `ALLOWED_DOMAINS` | `example.test,*.example.test` (`*` accepts any domain) |
| `ALLOWED_NETWORKS` | the RFC 1918 ranges and `fc00::/7` |
| `SMTP_HOSTNAME` | `mailsink.test` |
| `SMTP_PORT` | `25` |
| `MAX_MESSAGE_SIZE` | `26214400` (25 MiB) |
| `MAX_RECIPIENTS` | `100` |
| `MAX_CONNECTIONS` | `100` |
## Run
```sh
docker compose up -d --build
sudo ./egress-lockdown.sh # optional second layer; see below
```
Point applications at the Docker host on port 25. Any username and password
are accepted, so apps configured for authenticated SMTP work unchanged. There
is no TLS: clients must allow a plaintext connection.
`ALLOWED_NETWORKS` is checked against the client's source address. Clients on
the network reach the container through Docker's NAT, which keeps their real
address. A connection from the Docker host itself goes through Docker's
userland proxy, so it appears to come from the bridge gateway (in
`172.16.0.0/12`). If you narrow `ALLOWED_NETWORKS`, tests run on the host
itself will be refused.
## Blocking egress at the firewall
Docker can't publish a port from an `internal` network, so the container sits
on a normal bridge (`br-mailsink`). `egress-lockdown.sh` adds iptables rules
that drop every new connection starting from that bridge, both out to the
network and to the Docker host itself. Inbound SMTP and its replies still
pass. Run it as root after the container is up, and again after a reboot.
`--remove` takes the rules out. The script assumes Docker's iptables firewall
backend.
+28
View File
@@ -0,0 +1,28 @@
# Settings live in the Dockerfile; this file only runs the image.
services:
mailsink:
build: .
image: mailsink:local
restart: unless-stopped
# Publish to the test network. Bind to one host address rather than all
# of them if the host is also on a network that must not reach it, e.g.
# "10.20.0.5:25:25".
ports:
- "25:25"
networks:
- mailsink
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
# The server writes nothing, and this keeps Docker from storing even
# a startup error.
logging:
driver: none
networks:
mailsink:
driver: bridge
enable_ipv6: false
# A fixed bridge name lets egress-lockdown.sh find the network.
driver_opts:
com.docker.network.bridge.name: br-mailsink
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Stops the mailsink container from opening any connection of its own.
# Inbound SMTP on the published port and the replies to it still pass; any
# new connection that starts inside the br-mailsink bridge is dropped, both
# forwarded ones (DOCKER-USER) and ones to the Docker host itself (INPUT).
#
# The server has no outbound code, so this is a second layer. Run it as root
# on the Docker host after `docker compose up`, and again after a reboot or
# a Docker restart (Docker rebuilds its chains, but leaves DOCKER-USER alone
# while it is running). `--remove` takes the rule out.
set -euo pipefail
bridge=br-mailsink
rule=(-i "$bridge" -m conntrack --ctstate NEW -m comment --comment mailsink-no-egress -j DROP)
if [[ ${1:-} == --remove ]]; then
for chain in DOCKER-USER INPUT; do
while iptables -D "$chain" "${rule[@]}" 2>/dev/null; do :; done
done
exit 0
fi
if ! ip link show "$bridge" >/dev/null 2>&1; then
echo "egress-lockdown: bridge $bridge not found; start the container first" >&2
exit 1
fi
for chain in DOCKER-USER INPUT; do
iptables -C "$chain" "${rule[@]}" 2>/dev/null || iptables -I "$chain" "${rule[@]}"
done
+3
View File
@@ -0,0 +1,3 @@
module mailsink
go 1.23
+492
View File
@@ -0,0 +1,492 @@
// 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
}