- Go 100%
| .gitignore | ||
| config.example.toml | ||
| config.go | ||
| config_test.go | ||
| e2e_test.go | ||
| go.mod | ||
| go.sum | ||
| honeypot.go | ||
| honeypot_test.go | ||
| logging.go | ||
| main.go | ||
| README.md | ||
| server.go | ||
smtppot
An SMTP honeypot: it talks real SMTP (including STARTTLS), records who spammers try to send to and what the message subject is - and throws the message body away.
- TOML configuration - everything lives in one config file.
- STARTTLS - advertise and accept opportunistic TLS upgrades.
- Envelope logging only - sender, recipients, subject and size are logged; the message body is never stored, and even the other headers are discarded.
- Configurable rejection - rejection reply code and message text are
configurable, including
{sender}/{rcpt}/{user}/{domain}/{helo}placeholders and an optional tarpit delay. - Structured logging (
textorjson,log/slog), connection and message size limits, graceful shutdown on SIGINT/SIGTERM.
Building
go build -o smtppot .
Running
./smtppot -config config.toml
Without -config, the config.toml in the working directory is used; if
that does not exist either, built-in defaults are used (:25, logging to
stderr, accept everything).
Port 25 requires privileges; use e.g. setcap cap_net_bind_service=ep ./smtppot, run it in a network namespace/container, or pick a high port
such as :2525 for testing.
A ready-made starting point is config.example.toml; the Configuration section below explains each key.
STARTTLS certificate
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \
-keyout key.pem -out cert.pem -days 365 -subj "/CN=mx.example.org"
Reference the files in the config:
[tls]
enabled = true
cert_file = "/etc/smtppot/cert.pem"
key_file = "/etc/smtppot/key.pem"
What gets logged
Every session produces structured log records:
msg |
fields |
|---|---|
helo |
remote, helo, tls |
mail_from |
remote, sender (plus accepted, reason if refused) |
rcpt_to |
remote, recipient, accepted |
message |
remote, sender, recipients, subject, bytes, action |
The message.subject is the value of the Subject: header, RFC 2047
decoded, with control characters stripped and optionally truncated
(logging.max_subject_len). Nothing else of the message content is kept:
the body is streamed past, only its total byte count is recorded.
message.truncated=true means the client aborted the transfer mid-flight.
Addresses can be suppressed: with logging.log_addresses = false the HELO
name, sender and recipients are logged as the literal redacted, and with
logging.log_subject = false the subject field is omitted entirely and
message headers are never parsed at all.
At logging.level = "debug" the raw protocol transcript of every session is
logged as well, which is handy to study odd clients (suppressed when
log_addresses is off, since the transcript contains the addresses).
Operating modes (honeypot.mode)
| mode | behaviour |
|---|---|
accept |
Accept everything (default). Addresses and subjects are captured. |
reject_rcpt |
Log the envelope, but reject every RCPT TO. Subjects are never captured because no message data is ever accepted. |
reject_data |
Receive the full message and log sender/recipients/subject, then answer with the rejection - the spammer's send is counted as failure after wasting their resources. |
Rejections (in both reject modes, and require_starttls refusals) use the
configured reply:
[honeypot]
mode = "reject_data"
rejection_code = 550
rejection_message = "<{rcpt}>: Recipient address rejected: User unknown in local recipient table"
tarpit_delay = "5s"
rejection_message supports the placeholders {sender}, {rcpt},
{user}, {domain} and {helo} (see Configuration for
the mailbox-name/domain split and a redirect-style example); addresses are
sanitised so a client cannot forge SMTP reply lines.
tarpit_delay inserts a pause before each rejection reply to slow down
automated senders.
Note that reject_data rejections arrive after the message was received -
that is exactly what makes it interesting for a honeypot; the sender's
message is collected for intelligence and then refused.
Configuration
Every key shown can be omitted; the default is used then.
[server]
| key | default | meaning |
|---|---|---|
listen |
:25 |
TCP listen address |
hostname |
machine name | advertised in greeting/EHLO |
max_recipients |
100 |
RCPT TO limit per message (0 = unlimited) |
max_message_bytes |
10485760 |
message size limit in bytes (0 = unlimited) |
max_line_length |
2000 |
longest SMTP command line (0 = unlimited) |
max_connections |
256 |
concurrent connections (0 = unlimited) |
read_timeout |
"5m" |
per-connection read deadline |
write_timeout |
"5m" |
per-connection write deadline |
[tls]
| key | default | meaning |
|---|---|---|
enabled |
false |
advertise and support STARTTLS (needs cert + key) |
cert_file |
- | PEM certificate chain |
key_file |
- | PEM private key |
require_starttls |
false |
refuse MAIL FROM on non-TLS connections (530) |
min_version |
"1.2" |
"1.0", "1.1", "1.2" or "1.3" |
[honeypot]
| key | default | meaning |
|---|---|---|
mode |
"accept" |
"accept", "reject_rcpt" or "reject_data" |
rejection_code |
550 |
SMTP reply code used for rejections |
rejection_message |
<{rcpt}>: ... |
rejection text; placeholders {sender}, {rcpt}, {user}, {domain}, {helo} |
rejection_enhanced_code |
"" |
enhanced status code ("5.1.1", "none", or "" for a generic one) |
tarpit_delay |
"0s" |
pause before each rejection reply |
{user} and {domain} split the rejected recipient at the last @, which
makes redirect-style rejections possible:
[honeypot]
mode = "reject_rcpt"
rejection_code = 550
rejection_enhanced_code = "5.1.1"
rejection_message = "<{rcpt}>: User unknown. Please send to {user}@catchall.example.org instead"
Note that these placeholders are protocol output, not logging: the sending client always already knows the addresses it transmitted, so they are never redacted in replies.
[logging]
| key | default | meaning |
|---|---|---|
file |
stderr |
stderr, stdout or file path (append mode) |
format |
"text" |
"text" or "json" |
level |
"info" |
debug (incl. protocol transcript), info, warn, error |
log_addresses |
true |
log HELO name, sender and recipients; false logs redacted instead |
log_subject |
true |
log the message subject; false skips subject recording (and header parsing) entirely |
max_subject_len |
512 |
truncate subjects (0 = no limit) |
Setting log_addresses = false also suppresses the raw protocol
transcript of level = "debug", since that unavoidably contains the
envelope addresses.
systemd unit
/etc/systemd/system/smtppot.service:
[Unit]
Description=SMTP honeypot
After=network.target
[Service]
Type=simple
User=smtppot
ExecStart=/usr/local/bin/smtppot -config /etc/smtppot/config.toml
Restart=on-failure
AmbientCapabilities=CAP_NET_BIND_SERVICE
[Install]
WantedBy=multi-user.target
Testing
go test ./...
Privacy note
Logged senders, recipients and subjects can contain personal data of third parties. Run this only on infrastructure you are allowed to monitor, keep the log file protected and rotate/purge it as appropriate.
Acknowledgements
Protocol handling by emersion/go-smtp, configuration parsing by BurntSushi/toml.