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:
@@ -0,0 +1 @@
|
||||
/mailsink
|
||||
+63
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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
|
||||
Executable
+30
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user