Revisiting My Personal Email Setup
It’s been entirely too long since I put real effort into my email server. It was time to work on it again. By work on, I mean replace. I used a lot of my previous work from /post/2016/2016-10-15-email-server-writeup, but obviously things have changed since then. And I decided to add some things too; like using certbot & fail2ban.
Some basics (getting it installed!)
I’m still a big FreeBSD user. When I want to do something right, rather than fast; it’s my destination. That said, I really do like the pkg tools, but some things require ports.
I’d first installed everything from pkg:
- postgresql14-server (at this writing, 96 is available, should be fine)
- postgresql14-contrib (for crypto support!)
- postgresql14-client
- postfix
- dovecot (v2)
- dovecot-pigeonhole (sieve support)
- rspamd
- redis (used by rspamd)
- clamav
- py38-certbot
- py38-certbot-dns-cloudflare
- py38-fail2ban
- unbound (not sure what added this, but I did end up using it)
Later on, I realized some things needed individual compiling in order to get the features I was after (usually, postgres). I also never paid attention to the idea that I could “lock” pkg versions; which saved me from recompiling:
- pkg lock postgresql14-client
- pkg lock python38
- pkg lock zstd
Eventually these got recompiled from ports:
- postfix (add PGSQL)
- dovecot (add PGSQL)
- dovecot-pigeonhole (just for consistency)
- fail2ban (Avoid old PGSQL client)
Setting up the database
(none of this changed since my last foray)
I’m assuming you’ve done the “initdb” and started up postgresql by this point. On FreeBSD, you’d do this by (as root):
# sysrc postgresql_enable=YES
# service postgresql initdb
# service postgresql start
I created a separate user for the email programs to interact with. ‘postgres’ is the default root-level user in my postgresql server (your user may vary based on system).
# psql -U postgres -d template1
create user email with password 'my-password';
create database mail with owner email;
\c mail
create extension pgcrypto;
\q
Now we can create some tables and views. I chose to do this in a way I’d been using for a long time, which isn’t always the easiest to manage, but prevents you from doing stupid things. There will be 3 tables - transport, user, and aliases - and two views (which the programs will largely be using for reference) - view_aliases, and view_users.
# psql -U email -d mail
create table transport (
id serial primary key,
domain varchar(70) not null unique, -- adjust size to suit your needs
destination varchar(20) not null default 'virtual:');
create table users (
id serial primary key,
username varchar(128) not null,
password varchar(128) not null,
domain_id int references transport(id),
enabled boolean not null default true);
create unique index users_username_domain_idx on test_users (lower(username), domain_id);
create table aliases (
id serial primary key,
username varchar(50) not null,
domain_id int references transport(id),
destination varchar(128) );
create unique index alias_username_domain_idx on aliases (lower(username), domain_id);
-- and now the views
CREATE VIEW view_users AS
SELECT (users.username || '@' || transport.domain) AS email, users.password
FROM users
LEFT JOIN transport ON users.domain_id = transport.id;
CREATE VIEW view_aliases AS
SELECT (aliases.username || '@' || transport.domain) AS email, aliases.destination
FROM aliases
LEFT JOIN transport ON aliases.domain_id = transport.id;
This gives us our table structure. Obviously, I want to be able to receive my email, so let’s insert a single entry for testing, and so you can see how it works together. I’ll add an alias just to show that interaction too.
insert into transport (domain) VALUES ('binarydragon.com');
-- I'm going to assume that this gave is id=1 for the rest of this:
insert into users (username, domain_id, password) values ('kovus', 1, '{MD5}' || crypt('mypassword', gen_salt('MD5')));
-- add an alias too.
insert into aliases (username, domain_id, destination) values ('postmaster', 1, 'kovus@binarydragon.com');
Ok, I’m sure that last bit might leave someone scratching their heads asking “why doesn’t the alias destination take advantage of the transport table?” It’s simply because you might want to accept email for an address that then gets forwarded off to another domain. Such as accepting a vanity email for “kovus@binarydragon.com” and forwarding that off to “kovus@othermailhost.com.” Obviously I don’t own othermailhost.com, and won’t accept email for it, so it doesn’t go in my transport table (as we’ll see later), but I can pass it on. The reason to use the transport table to addresses we do receive is to avoid typographical errors.
Configuring postfix (basics)
So, onto the meat of the configuration. We have our data, so now we can start configuring postfix.
I’ve sort of stripped my main.cf config file down to the minimums. I’ve added some comments to try and make it easier to understand why.
compatibility_level = 3.6
# some basic directories
queue_directory = /var/spool/postfix
command_directory = /usr/local/sbin
daemon_directory =/usr/local/libexec/postfix
data_directory = /var/db/postfix
# since postfix isn't going to manage the actual maildirs
# this value is sort of inconsequential.
mail_owner = postfix
# host configuration
myhostname = mail.binarydragon.com
inet_protocols = ipv4, ipv6
# use the machine's real IPs for inet_interfaces.
# I did want to show that you can use ipv6 addr's too!
inet_interfaces = 10.10.10.10, fe80::1
mynetworks_style = host
# I was relaying mail from my old server for a short period while DNS caught up.
# To do so, comment out the mynetworks_style line, and define it explicitly:
# mynetworks = 127.0.0.0/8 10.10.10.10/32, [fe80::1]
# ~100MB message size limit.
message_size_limit = 102400000
unknown_local_recipient_reject_code = 550
# Read the actual config on these two debug statements. They're here for consistency.
debug_peer_level = 2
debugger_command =
PATH=/bin:/usr/bin:/usr/local/bin:/usr/X11R6/bin
ddd $daemon_directory/$process_name $process_id & sleep 5
# paths to various programs & directories
# These will be defaults on FreeBSD.
sendmail_path = /usr/local/sbin/sendmail
newaliases_path = /usr/local/bin/newaliases
mailq_path = /usr/local/bin/mailq
setgid_group = maildrop
html_directory = /usr/local/share/doc/postfix
manpage_directory = /usr/local/man
sample_directory = /usr/local/etc/postfix
readme_directory = /usr/local/share/doc/postfix
meta_directory = /usr/local/libexec/postfix
shlib_directory = /usr/local/lib/postfix
# scan with rspamd & clamav milter before processing too much
# if you're not using milters, comment these two lines out!
smtpd_milters = inet:localhost:11332, unix:/var/run/clamav/clmilter.sock
milter_default_action = reject
# Recipient delimiter is neat. You should read up on it, especially
# if this is a personal server. It lets you use
# kovus+anything@binarydragon.com as an email and emails to that address
# will go to kovus@binarydragon.com automatically.
recipient_delimiter = +
# transport maps - enable this to use non-virtual destinations for domains:
# assumes that virtual domains' destination will be "virtual:"
# Lets us forward mail to other servers, eg:
# destination: smtp:127.127.127.127
transport_maps = pgsql:/usr/local/etc/postfix/sql-transport.cf
# use transport maps in conjunction with relay domains, for relaying data
# to offsite servers (eg, non-'virtual:' destinations)
relay_domains = pgsql:/usr/local/etc/postfix/sql-relaydomains.cf
# how to handle virtual mailboxes, where to look for info, etc.
virtual_mailbox_domains = pgsql:/usr/local/etc/postfix/sql-virtual.cf
virtual_uid_maps = static:5000
virtual_gid_maps = static:5000
virtual_mailbox_maps = pgsql:/usr/local/etc/postfix/sql-users.cf
virtual_alias_maps = pgsql:/usr/local/etc/postfix/sql-aliases.cf
# this virtual transport points to our dovecot instance.
virtual_transport = lmtp:inet:127.0.0.1:10025
# use sasl for authentication
smtpd_sasl_type = dovecot
smtpd_sasl_path = private/auth
smtpd_sasl_auth_enable = yes
broken_sasl_auth_clients = yes
# enable TLS for security
smtpd_tls_cert_file = /usr/local/etc/letsencrypt/live/mail.binarydragon.com/fullchain.pem
smtpd_tls_key_file = /usr/local/etc/letsencrypt/live/mail.binarydragon.com/privkey.pem
smtpd_tls_loglevel = 1
smtpd_tls_received_header = yes
# allower newer tls protocols
smtpd_tls_protocols = TLSv1.2, TLSv1.1, !TLSv1, !SSLv2, !SSLv3
smtp_tls_protocols = TLSv1.2, TLSv1.1, !TLSv1, !SSLv2, !SSLv3
smtp_tls_ciphers = high
smtpd_tls_ciphers = high
smtpd_tls_mandatory_protocols = TLSv1.3, TLSv1.2, TLSv1.1, !TLSv1, !SSLv2, !SSLv3
smtp_tls_mandatory_protocols = TLS1.3, TLSv1.2, TLSv1.1, !TLSv1, !SSLv2, !SSLv3
smtp_tls_mandatory_ciphers = high
smtpd_tls_mandatory_ciphers = high
# disable deprecated ciphers
smtpd_tls_mandatory_exclude_ciphers = MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL
smtpd_tls_exclude_ciphers = MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL
smtp_tls_mandatory_exclude_ciphers = MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL
smtp_tls_exclude_ciphers = MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL
tls_preempt_cipherlist = yes
# enable opportunistic encryption
smtpd_tls_security_level = may
smtp_tls_security_level = may
# setup restrictions
# additional detail available on: http://www.postfix.org/SMTPD_ACCESS_README.html#lists
smtpd_helo_restrictions =
permit_mynetworks,
permit_sasl_authenticated,
reject_non_fqdn_hostname,
reject_invalid_hostname,
permit
smtpd_sender_restrictions =
permit_sasl_authenticated,
permit_mynetworks,
reject_non_fqdn_sender,
reject_unknown_sender_domain,
permit
smtpd_recipient_restrictions =
permit_mynetworks,
reject_unauth_pipelining,
reject_non_fqdn_recipient,
reject_unknown_recipient_domain,
permit_sasl_authenticated,
reject_unauth_destination,
reject_rbl_client zen.spamhaus.org=127.0.0.[2..11],
reject_rhsbl_sender dbl.spamhaus.org=127.0.1.[2..99],
reject_rhsbl_helo dbl.spamhaus.org=127.0.1.[2..99],
reject_rhsbl_reverse_client dbl.spamhaus.org=127.0.1.[2..99],
warn_if_reject reject_rbl_client zen.spamhaus.org=127.255.255.[1..255],
permit
A few points to note:
- I switched to using LetsEncrypt certs. See the section on certbot in this writeup.
- I do reference a few files for the SQL lookups, they’re coming.
- I don’t meant to get into the nitty-gritty of the restrictions on the bottom, so the basics:
- Order matters! We should do the least expensive checks first!
permit_sasl_authenticated
- Allow authenticated users to send emails, no matter what stage.permit_mynetworks
- Allow our emails from our servers based on mynetworks (early in the config).reject_*
- It’s pretty efficient to block servers that are misconfigured, such as not providing FQDN, having unknown sender domains (invalid), etc. One could also find information on rejecting domains failing an SPF record lookup, but that’s a different scope too. A suggestion from another system (Spamhaus, I think) is to let your spam filter software (rspamd) handle that.reject_rbl_client ...
- Realtime DNS-based Blackhole List. Nice concept - use a 3rd party service (like spamhaus) to reject incoming servers in pseudo-realtime. This helps to reject servers which start sending out spam (due to misconfigurations, or bad users) and the RBLs include ways for admins to get their servers unlisted once the problem is taken care of. It’s a little more expensive to do RBL than the other checks above it, so we do that one last.1
Here’s the additional configuration files for postfix, for doing the database lookups. Do note that you’ll be providing a user/password to your database in these files. Postfix is a read-only lookup, so you can get into a bit more depth and create a read-only user if you want. The only variation between these files is the query.
#sql-aliases.cf
user = email
password = mypassword
dbname = mail
hosts = localhost
query = SELECT destination FROM view_aliases WHERE email = '%s'
#sql-relaydomains.cf
user = email
password = mypassword
dbname = mail
hosts = localhost
query = SELECT domain FROM transport WHERE destination != 'virtual:' AND domain = '%s'
#sql-transport.cf
user = email
password = mypassword
dbname = mail
hosts = localhost
query = SELECT destination FROM transport WHERE domain = '%s' AND destination != 'virtual:'
#sql-users.cf
user = email
password = mypassword
dbname = mail
hosts = localhost
query = SELECT true FROM view_users WHERE email = '%s'
#sql-virtual.cf
user = email
password = mypassword
dbname = mail
hosts = localhost
query = SELECT destination FROM transport WHERE destination = 'virtual:' AND domain = '%s'
Last item for postfix: getting it to listen on SMTPS (SSL support). Add this to your master.cf
file. I shoved it below the smtp
line.
smtps inet n - n - - smtpd
-o syslog_name=postfix/smtps
-o smtpd_tls_wrappermode=yes
So, that’s it for postfix. Now we can get into dovecot’s configuration, since postfix at this point will simply be trying to hand email to dovecot!
Dovecot configuration
Dovecot started spreading their configuration across multiple files, so it somewhat feels like it’s a bit all over the place, but it’s pretty straightforward.
There’s not a lot of stuff in my main dovecot.conf file, but here’s what little there is. We do need lmtp, since that’s how postfix will be delivering email to dovecot (for storage into users’ maildirs).
Personally, I remove pop3, but that’s up to you.
# dovecot.conf
# Protocols we want to be serving.
protocols = imap pop3 lmtp
dict {
}
# load the actual config files, this is were all the meaty
# configuration for dovecot is done.
!include conf.d/*.conf
# I don't actually have a local.conf, but it tries to load one anyway.
!include_try local.conf
I’m going to only hit on what seems to me to be the important config files (and specifically the non-commentary) from conf.d/*.conf, as a bunch of them have sane defaults. A lot of them just exist with everything commented out, waiting for you to choose what to do!
Authentication & storage
Break down authentication into 3 files. Also includes the lookup for where to store their email (userdb). I should probably look at using something other than PLAIN-MD5, but the whole transaction should be using TLS anyway, so it’s still protected in transit. Since dovecot is read-only on the DB, you can use a different sql user/pass than before, if wanted.
# 10-auth.conf
auth_mechanisms = plain login
!include auth-sql.conf.ext
# auth-sql.conf.ext
# we'll define two things here, where to get the sql data (auth)
# and where to store email. We're avoiding getting the
# email storage location from the database, since it's the same.
passdb {
driver = sql
args = /usr/local/etc/dovecot/dovecot-sql.conf.ext
}
userdb {
driver = static
args = uid=vmail gid=vmail home=/home/vmail/%d/%n
}
# /usr/local/etc/dovecot/dovecot-sql.conf.ext
driver = pgsql
connect = host=localhost dbname=mail user=email password=mypassword
default_pass_scheme = PLAIN-MD5
password_query = SELECT email AS user, password FROM view_users WHERE email='%u'
I don’t think I made any changes to 10-director.conf or 10-logging.conf, so on to 10-mail.conf. We need to specify the mail locations here, as well as define one of the namespaces (inbox).
# 10-mail.conf
mail_location = maildir:/home/vmail/%d/%n/Maildir
namespace inbox {
inbox = yes
}
We definitely need to modify 10-master.conf, as these’s some services to tweak. We need to enable lmtp on a specific ip/port, and need to add a way for postfix to auth users (for sending email). Everything else was in the example config.
# 10-master.conf
service imap-login {
inet_listener imap {
}
inet_listener imaps {
}
}
service pop3-login {
inet_listener pop3 {
}
inet_listener pop3s {
}
}
service lmtp {
unix_listener lmtp {
}
# first addition, add LMTP on a specific port
inet_listener lmtp {
address = 127.0.0.1 ::1
port = 10025
}
}
service imap {
}
service pop3 {
}
service auth {
unix_listener auth-userdb {
}
# second addition, create a unix sock for postfix to authenticate
unix_listener /var/spool/postfix/private/auth {
mode = 0666
user = postfix
group = postfix
}
}
service auth-worker {
}
service dict {
unix_listener dict {
}
}
The only reason I had to edit 10-ssl.conf was to give it the same cert data I gave to postfix, since it’s all relevant to both services.
# 10-ssl.conf
ssl = yes
ssl_cert = </usr/local/etc/letsencrypt/live/mail.binarydragon.com/fullchain.pem
ssl_key = </usr/local/etc/letsencrypt/live/mail.binarydragon.com/privkey.pem
I did want to change the way ‘Junk’ is handled, as I use \Junk as the folder for spam in my system.
# 15-mailboxes.conf
namespace inbox {
mailbox Junk {
auto = subscribe
special_use = \Junk
}
}
Added sieve/managesieve to the 20-* configs. Not sure I’ve implemented it correctly, as I don’t actually have anything using it currently.
# 20-imap.conf (optional edits)
# this might be standard now, since spam handling is no longer done via the antispam plugin.
protocol imap {
mail_plugins = $mail_plugins
mail_max_userip_connections = 30
}
# 20-lmtp.conf (optional edits)
# Wanted to add a default postmaster address and the sieve plugin (90-sieve*.conf).
protocol lmtp {
postmaster_address = postmaster@binarydragon.com
mail_plugins = $mail_plugins sieve
}
# 20-managesieve.conf (optional file)
# this is a completely new file, part of using the sieve series of plugins.
# managesieve allows users to create sieve files which can apply filters
# to their emails.
service managesieve-login {
inet_listener sieve {
port = 4190
}
service_count = 1
vsz_limit = 64M
}
service managesieve {
process_limit = 1024
}
protocol sieve {
managesieve_max_line_length = 65536
mail_max_userip_connections = 10
managesieve_max_compile_errors = 5
}
No changes to 20-pop3.conf, 90-acl.conf.
Adding in 90-sieve (using pigeonhole) adds the functionality to put emails into/from the \Junk folder in order to train the system on whether that email is spam or ham (not-spam).
# 90-sieve.conf
# Mostly torn from:
# https://doc.dovecot.org/configuration_manual/howto/antispam_with_sieve/#howto-antispam-with-imapsieve
protocol imap {
mail_plugins = $mail_plugins imap_sieve
}
plugin {
sieve_plugins = sieve_imapsieve sieve_extprograms
# From elsewhere to Spam folder
imapsieve_mailbox1_name = Junk
imapsieve_mailbox1_causes = COPY
imapsieve_mailbox1_before = file:/usr/local/etc/dovecot/sieve/report-spam.sieve
# From Spam folder to elsewhere
imapsieve_mailbox2_name = *
imapsieve_mailbox2_from = Junk
imapsieve_mailbox2_causes = COPY
imapsieve_mailbox2_before = file:/usr/local/etc/dovecot/sieve/report-ham.sieve
sieve_pipe_bin_dir = /usr/local/etc/dovecot/sieve
sieve_global_extensions = +vnd.dovecot.pipe +vnd.dovecot.environment
}
Combine this with the following sieve+script for spam:
# report-spam.sieve
require ["vnd.dovecot.pipe", "copy", "imapsieve", "environment", "variables"];
if environment :matches "imap.user" "*" {
set "username" "${1}";
}
pipe :copy "learn-spam.sh" [ "${username}" ];
#!/bin/sh
/usr/local/bin/rspamc learn_spam
And these for ham. The ham script is longer, as it doesn’t care if you move something into the Trash; whether it’s spam or normal email. This does have the side effect of that if your users don’t put spam into the \Junk folder (and merely delete it), then the system never learns from them.
# report-ham.sieve
require ["vnd.dovecot.pipe", "copy", "imapsieve", "environment", "variables"];
if environment :matches "imap.mailbox" "*" {
set "mailbox" "${1}";
}
if string "${mailbox}" "Trash" {
stop;
}
if environment :matches "imap.user" "*" {
set "username" "${1}";
}
pipe :copy "learn-ham.sh" [ "${username}" ];
#!/bin/sh
/usr/local/bin/rspamc learn_ham
(optional) I wanted to include this here, since all the rest of the sieve documentation is here (at least for now). This is a basic sieve filter, which takes email having the spam header flags, and puts them into the Junk folder for the user.
# /home/vmail/sieve/sieve_before.d/spam.sieve (optional)
require ["fileinto"];
# rule:[SPAM]
# spamassassin uses X-Spam-Flag: YES.
# rspamd uses X-Spam: yes
if anyof (header :contains "X-Spam-Flag" "YES",
header :contains "X-Spam" "yes")
{
fileinto "Junk";
stop;
}
That pretty much wraps up dovecot configuration.
Rspamd
I didn’t have too many changest to rspamd. The defaults in v3 seem to be pretty decent, though I can’t yet vouch for how good it works (since my spam count is pretty low currently).
RBL: I did sign up for a free Spamhaus Data Query Service account (since it’s non-commercial/personal): https://www.spamhaus.com/free-trial/sign-up-for-a-free-data-query-service-account/ . Then this gets used in local.d/rbl.conf
for rspamd to override the queries for spamhaus.
# local.d/rbl.conf
rbls {
spamhaus {
rbl = "myspamhauskey.zen.dq.spamhaus.net";
}
"DBL" {
rbl = "myspamhauskey.dbl.dq.spamhaus.net";
}
}
Also told rspamd to use localhost for redis, which enabled a bunch of other functionality:
# local.d/redis.conf
write_servers = "localhost";
read_servers = "localhost";
Finally, enabled the worker proxy. I think I copied this completely from the default worker-proxy.conf file, but I have learned that you can just specify the “updates” when using local.d (unlike if you created override.d/worker-proxy.inc
);
# local.d/worker-proxy.conf
milter = yes; # Enable milter mode
timeout = 120s; # Needed for Milter usually
upstream "local" {
default = yes;
self_scan = yes;
hosts = "localhost";
}
count = 4; # Do not spawn too many processes of this type
max_retries = 5; # How many times master is queried in case of failure
discard_on_reject = false; # Discard message instead of rejection
quarantine_on_reject = false; # Tell MTA to quarantine rejected messages
spam_header = "X-Spam"; # Use the specific spam header
reject_message = "Spam message rejected"; # Use custom rejection message
RSpamd has information on doing training, but I opted to let it start fresh. We’ll see how good/bad this turns out to be later.
clamav, clamav-milter
ClamAV is a decent free A/V scanner. I use it because it’s better than nothing, though, definitely not top-ranked. Here’s the clamd.conf & clamav-milter.conf files I use. They’re pretty basic, primary interest is the LocalSocket (clamd.conf), and the ClamdSocket/MilterSocket from the milter conf.
Since the last time I’d done this, ClamAV added “Potentially Unwanted Application” identifiecation. I decided to turn it on, as I looked through the PUA categories list at https://github.com/vrtadmin/clamav-faq/blob/master/faq/faq-pua.md and decided it seemed pretty reasonable.
# clamd.conf
LogFile /var/log/clamav/clamd.log
LogSyslog yes
LogFacility LOG_MAIL
PidFile /var/run/clamav/clamd.pid
DatabaseDirectory /var/db/clamav
LocalSocket /var/run/clamav/clamd.sock
FixStaleSocket yes
StreamMaxLength 30M
User clamav
AllowSupplementaryGroups yes
ScanMail yes
DetectPUA yes
# see https://github.com/vrtadmin/clamav-faq/blob/master/faq/faq-pua.md for categories.
# clamav-milter.conf
MilterSocket /var/run/clamav/clmilter.sock
FixStaleSocket yes
User clamav
AllowSupplementaryGroups yes
PidFile /var/run/clamav/clamav-milter.pid
ClamdSocket unix:/var/run/clamav/clamd.sock
LogSyslog yes
LogFacility LOG_MAIL
LogInfected Basic
LogClean Basic
That pretty much sums everything up.
Certbot
LetsEncrypt aims to push people to automate their certificate management (renewals, really). It’s a great concept, pretty good implementation, and since I use CloudFlare for DNS (and in the case of websites; proxying & caching) the plugin makes the process simple.
First things first: get your cloudflare APi token for use with certbot. Then shove that into a file such as /usr/local/etc/certbot.cf.token
:
dns_cloudflare_api_token = my-token-value
Then run certbot (for the first-time generation) with the settings you need. I do a lot of domains to make things nice & clean with the various domains I manage, so I have some delays for cloudflare propagation in my command as well:
certbot --dns-cloudflare --dns-cloudflare-credentials /usr/local/etc/certbot.cf.token --dns-cloudflare-propagation-seconds 60 -d mail.binarydragon.com -d mail.some-other-domain.com
There’s a weekly periodic script that is created on FreeBSD to which you can then add some entries to /etc/periodic.conf to execute it. At time of writing, I haven’t verified that this is all correct, but this is what I shoved into my /etc/periodic.conf
.
weekly_certbot_enable="YES"
weekly_certbot_post_hook="service dovecot reload; service postfix reload"
weekly_certbot_custom_args="--dns-cloudflare --dns-cloudflare-credentials /usr/local/etc/certbot.cf.token --dns-cloudflare-propagation-seconds 60"
Fail2Ban
Eventually, crap will find the mail server, and since we need to allow any mail server to initiate a conversation with us, there’s not a pretty way to filter things out.
Much like rspamd; fail2ban is configured by generating .local files, or puttings content into jail.d/*.conf
files. So I created a jail.local (based on https://cmcenroe.me/2016/06/04/freebsd-pf-fail2ban.html) and a couple jail.d/*.conf files:
# jail.local
[DEFAULT]
banaction_allports = pf[actiontype=<allports>]
banaction = pf[actiontype=<multiport>]
# jail.d/bsd-ssh-pf.conf
# Filteres out failed SSH logins. Careful with this, you can filter
# yourself out by failing your password authentication yourself!
[bsd-ssh-pf]
enabled = true
filter = bsd-sshd
logpath = /var/log/auth.log
findtime = 600
maxretry = 3
bantime = 86400
# jail.d/email.conf
[postfix-sasl]
enabled = true
findtime = 6h
#findtime = 600
bantime = 7d
maxretry = 3
[dovecot]
enabled = true
findtime = 600
bantime = 86400
maxretry = 3
I raised my postfix-sasl (smtp auth) time/bantime after seeing an IP show up every 60-120 minutes with a failed auth. I figure someone has a very slow scanner operating.
That same article also had a nice little shell script for looking at the fail2ban jail.d config files to find possible tables in pf. I have a slight modification to the grep
filter in my own version, so that I don’t need to rename jail entries to add -pf to them. So here’s my showbans.sh:
#!/bin/sh
#
# Show banned IPs in PF's 'fail2ban' tables.
for i in `cat /usr/local/etc/fail2ban/jail.d/*.conf | grep -- "^\[" | sed 's/\[//' | sed 's/\]//'`; do
echo ${i}
pfctl -a "f2b/${i}" -t f2b-${i} -Ts
done
exit 0
Local Unbound
After doing all of this work and deploying things, I realized I started getting errors in my postfix log about spamhaus blocking checks and thus causing a “retry later” response back to the sender.1 Signing up to Spamhaus helped a bit, but then I saw more warnings in rspamd’s logfile from other sites it was checking with.
It turns out that the reason for this is that these systems all put in per-server query rates. The public dns servers from CloudFlare, Google, L3, Verisign, etc end up exceeding these limits, and then you get failures. I still wanted to use 1.1.1.1 for my primary DNS, but I figured I could use the local provider’s DNS servers for these services. Unbound lets you specify the DNS servers to use for specific domains/zones. I created my /etc/unbound/conf.d/forward-rbls.conf
to do exactly this. Though you’ll want to change the IPs provided here with the local service’s DNS IPs you have. (The ones used as an example here are the CloudFlare IPs).
forward-zone:
name: "spamhaus.org."
forward-addr: 1.1.1.1
forward-addr: 1.0.0.1
forward-zone:
name: "uribl.com."
forward-addr: 1.1.1.1
forward-addr: 1.0.0.1
forward-zone:
name: "surbl.org."
forward-addr: 1.1.1.1
forward-addr: 1.0.0.1
forward-zone:
name: "dnswl.org."
forward-addr: 1.1.1.1
forward-addr: 1.0.0.1
Fire up service local_unbound start
and let it handle your DNS lookups!
Launch! And pray. Again.
Typical email things will ensue. Typos, failure to include comments in my post, etc. Getting filtered off the Internet is probably a good one.
Final thoughts
There’s probably things I missed in my writeup, even though I only did the implementation in the last few days before this writeup. Email is still a pain, but I wasn’t ready yet to move over to another service provider. Something about being able to do this on a reasonably large VM for $10 instead of shelling out $30+ for MS 365 Business or something similar (where I can have my multiple domains, etc) still has a selling point.
Hopefully something in here is useful for you. My previous writeup helped me redo my setup and formed the foundation of this writeup. Hopefully it’s not too scatterbrained to be useful.
-
As mentioned (hopefully) in my section on using local Unbound: spamhaus will end up giving you false positives on filtering if you use large public dns servers like CloudFlare; Google; L3, etc. While these services are great for general DNS; spamhaus was getting attacked through those services; so the put limiters in place, and those services will end up returning a bad result eventually. ↩︎