Skip to content

Latest commit

 

History

History
210 lines (161 loc) · 8.21 KB

File metadata and controls

210 lines (161 loc) · 8.21 KB

mailguard

Detect abnormal outbound SMTP bursts on a Mail-in-a-Box server and freeze the offending SASL account before the spam actually goes out.

Built after a real incident: a single mailbox password leaked, was used from ~30 rotating AWS EC2 IPs to blast ~4,000 messages in ~40 hours through authenticated submission. Nothing in the standard MiaB stack (fail2ban, Spamhaus RBL, greylisting, DKIM, DMARC) catches this pattern — those defenses protect against incoming mail and against unauthenticated abuse. Once the attacker has a valid SASL credential, they're inside the trust boundary.

mailguard closes that gap.

What it does

Runs every 60 seconds via a systemd timer. Two independent detection signals:

Signal Trigger
Submission burst ≥ N SASL auths from a single user within ~60s (default: 20)
Queue concentration ≥ M messages queued from a single sender at any moment (default: 50)

When either fires, mailguard atomically:

  1. Freezes the mailbox by prefixing the Dovecot password hash with !FROZEN:, invalidating the scheme header. The original hash is preserved inside the field so unfreeze is a trivial string strip. A password reset from the MiaB admin panel naturally overwrites the frozen field with a fresh hash.
  2. Holds all queued messages from the sender via postsuper -h. They move to Postfix's hold queue and will never be delivered until you explicitly release or purge them.
  3. Emails an alert to a configured admin address with the reason, sample recipients, and the exact commands to release or purge.

Nothing that was already in the queue gets delivered. Nothing new can be submitted by the frozen user. Alert lands within a minute of the burst.

Why not just rate-limit?

Rate limiting still lets some spam through — enough to blacklist your IP. Freeze-on-burst delivers zero spam once a threshold is crossed. Legitimate bursts (a newsletter, a mass transactional run) become an alert you glance at and release with one command:

mailguard release info@example.com

Scope and requirements

Tested on: Mail-in-a-Box (Ubuntu 22.04, Postfix, Dovecot, systemd).

Assumes:

  • Postfix log format at /var/log/mail.log (rsyslog default)
  • Dovecot passdb backed by MiaB's SQLite schema at /home/user-data/mail/users.sqlite (users table, password column storing {SHA512-CRYPT}... hashes)
  • systemd timer support
  • Python 3.10+ (standard library only, no dependencies)

Not tested on but likely works with light path changes: any Postfix + Dovecot setup where the passdb is queryable-writable and stores scheme-prefixed hashes.

Won't work on Exim, Sendmail, OpenSMTPD, or non-Dovecot SASL providers without a driver refactor. Contributions welcome.

Install

git clone https://github.com/USER/mailguard.git
cd mailguard
sudo ./install.sh

By default the installer starts mailguard in dry-run mode — it will detect and email alerts but will not freeze anything. Watch it for a few days, tune thresholds, then flip to enforcement:

sudo sed -i 's/"dry_run": true/"dry_run": false/' /etc/mailguard/config.json

Configuration

Edit /etc/mailguard/config.json:

Field Default Meaning
dry_run true If true, alert only — never actually freeze or hold.
submission_burst_threshold 20 SASL auths per user per window that trip a freeze.
submission_burst_window_seconds 60 Sliding window matches the timer interval.
queue_concentration_threshold 50 Queue messages from one sender that trip a freeze.
alert_email Where alerts go. Strongly recommend a local mailbox (external addresses may bounce during an incident or be delayed by rDNS/DMARC issues).
alert_from From: header for alerts.
mail_log_path /var/log/mail.log Path to Postfix log.
users_sqlite /home/user-data/mail/users.sqlite MiaB user database.
whitelist_users [] SASL usernames exempt from freeze (bulk senders you trust).
hostname Used in alert subject/body only.

CLI

mailguard status              # current state, top queue senders, frozen list
mailguard freeze USER REASON  # manual freeze (for one-off incidents)
mailguard unfreeze USER       # restore original password hash
mailguard purge USER          # delete all queue messages from sender (spam case)
mailguard release USER        # move held messages back to active queue (false-positive case)
mailguard watch               # single detection pass (called by systemd timer)

Typical incident response:

# 1. Alert arrives. Look at what was caught:
mailguard status

# 2. Confirm it was actually spam (sample the queued messages):
mailq | head -50

# 3a. Real spam — nuke it and rotate password via MiaB admin panel:
mailguard purge compromised@example.com
#   (then reset the mailbox password in the MiaB admin panel;
#    the password reset naturally clears the frozen state)

# 3b. False positive — release and whitelist the sender:
mailguard release newsletter@yourdomain.com
#   (add to whitelist_users in /etc/mailguard/config.json to prevent recurrence)

How the freeze works

Dovecot's SQL passdb (default_pass_scheme = SHA512-CRYPT) stores password hashes as {SHA512-CRYPT}$6$.... Dovecot parses the {SCHEME} prefix to decide how to verify the plaintext at auth time.

mailguard freezes an account by prepending !FROZEN: to that stored value:

before: {SHA512-CRYPT}$6$rounds=5000$...
after:  !FROZEN:{SHA512-CRYPT}$6$rounds=5000$...

Dovecot no longer recognizes the scheme header, so authentication fails regardless of what password is presented. The original hash is preserved verbatim inside the field, so mailguard unfreeze is a one-line string strip that restores auth exactly as before.

This mechanism has two nice properties:

  • MiaB-compatible. No Postfix config edits, no main.cf or master.cf touched, so MiaB updates and sudo mailinabox runs don't overwrite anything.
  • Naturally cleaned up by password reset. When the admin resets the mailbox password from the MiaB admin panel, the entire password field is overwritten with a fresh hash — the !FROZEN: prefix disappears automatically. No coordination needed between mailguard and the admin workflow.

Files installed

Path Purpose
/opt/mailguard/mailguard.py Main script
/usr/local/bin/mailguard CLI symlink
/etc/mailguard/config.json Configuration
/var/lib/mailguard/state.json Log offset, tracked freezes
/var/log/mailguard.log Activity log
/etc/systemd/system/mailguard.service Systemd unit
/etc/systemd/system/mailguard.timer Systemd timer (every 60s)

Uninstall

sudo ./uninstall.sh          # remove code, keep config and state
sudo ./uninstall.sh --purge  # remove everything

Limitations and honest caveats

  • This is not a substitute for good mailbox password hygiene. It's a containment layer for when a password does leak.
  • Alert delivery matters. If your alert address bounces or is delayed, you'll miss the notification. Use a local MiaB mailbox for alerts, or an external address on a domain that reliably accepts mail from your server.
  • If the compromised mailbox is the one receiving alerts, local LMTP delivery to its INBOX still works (freeze only breaks SASL auth, not incoming mail).
  • Thresholds need tuning per-site. The defaults were picked for a small MiaB deployment. A larger site with many bulk senders may want higher thresholds and populated whitelist_users.
  • First-run behavior: mailguard starts reading the log from now, not from history. Any ongoing burst at install time will be detected on the next tick (within 60s), not retroactively.
  • The queue concentration signal catches slow-drip attacks that stay under the submission burst threshold but accumulate in the queue over time. Both signals are needed.

Contributing

Issues and PRs welcome. Especially useful:

  • Exim / OpenSMTPD driver
  • Non-MiaB Dovecot passdb backends (LDAP, plain files)
  • Log rotation edge cases on other rsyslog configurations
  • Prometheus/OpenTelemetry metrics export

License

MIT — see LICENSE.