AbuseIPDB Checking With Postfix
Table of Contents
So if you’ve not heard, there’s this website called AbuseIPDB, which, no affiliation, is a website where webmasters can submit reports of abusive IP addresses, and then query those reports, either manually, or using their REST API. And this is how I did exactly that, to help cut down some of the spam on my email server. Let’s get started.
So here’s the deal. Spam is just something that, if you manage a mail server, are going to have to accept that it exists. There’s no way around that. But what you don’t have to accept is the spam itself, there’s plenty of ways to block it if you wish. Now, I already have the basics in: SPF, DKIM, DMARC, a few checks to DNSBLs / RBLs, but there’s still one other line of defense that I can add: Just a general abuse check.
Note: if you want to do something similar yourself, I’ve put some more cleaned-up code up in a repository. More on that at the end.
The Plan
So the goal for this idea was as such: Every time an email is received by Postfix, I want it to query the AbuseIPDB API for the client’s IP address, and use that to judge if it should reject the message or not. Given the API call limits per day of AbuseIPDB, and the amount of mail I receive, this isn’t an issue whatsoever. And hand-rolling my own API client using Python’s Requests lib, that was an extremely simple process. So, I have some way of querying an IP address, and receiving it’s abuse confidence, which is a value from 0% to 100%, where 100% is “abusive” and 0% is “not abusive”. And I know that Postfix has a system in place for sending incoming emails through a series of checks. So, how do I marry the two?
smtpd
Restrictions
A brief bit of background: How Postfix handles this.
Postfix has a series of configuration directives, of the form smtpd_X_rescrictions
, where X
is something like client
, recipient
, helo
, etc.
At every major stage of the SMTP transaction, Postfix can run a sequence of checks to say if a particular client or message is allowed to progress, or be sent a denial message.
We can use this to check, say, the connecting IP address against a lookup table, your HELO name’s validity with an MX or A record, if the IP is on a certain RBL, there’s a number of them.
And each can give a result that’s, according to Postfix, OK
, REJECT
, or DUNNO
.
OK
results are a “this is good, don’t bother processing any further”, say, have all servers on the local network have full relay access.
REJECT
results cause… a rejection, and DUNNO
is kinda the middle ground — a DUNNO
check behaves as if it didn’t exist: it doesn’t reject the message, but doesn’t give it the green light either, it just goes to the next step in the line.1
Well, one of the items you can slot into these restrictions is called a policy service, which will send a summary of the transaction as it stands to a program of your choosing, and it’ll expect back a result to act on.
And this is what we can use.
When Postfix wants to use a policy service, it opens a socket to the program, and sends it a series of attribute=value
lines, followed by a single blank line, to signify the end of it’s side of the conversation, and it’ll wait for a reply of the form action=X
, where X is a valid filter action, followed by a blank line.
One of the items that Postfix will send is client_address
.
And that’s what I need to check!
The Implementation
The bulk of the processing is a Python script:
|
|
To me it seemed pretty simple, but… okay that’s pretty long. But basically, here’s what happens:
Each invocation processes one connection, and therefore, one message.
The only thing it cares about is a client_address
line, so it only looks for that and no others.
After seeing this, it’ll send that to the check function which will URL-encode the address and query AbueIPDB’s API with it (line 51).
We ignore reports over 90 days old for this.
Now, if an HTTP response other than 200 was returned, we log an error, but pass the message through untouched. Otherwise, we run two checks:
- If the
isWhitelisted
field from the API response is true, then AbuseIPDB has whitelisted this IP for some reason. We don’t need to continue checking confidence, and can immediately return. If theWHITELIST_IMMEDIATE_OK
variable was set at the top, then also skip any other restrictions afterwards too by returning an OK response. - Otherwise, check the abuse confidence against the
MIN_CONFIDENCE
value. If it’s higher, then reject the message. The reject action is521
and notREJECT
, meaning that Postfix will return521
as the code and not its defaultREJECT
code, which is554
unless specified in the config.2
Assuming neither checks caught the message, we’ll send a DUNNO
back, so Postfix will continue evaluation.
However, if ADD_HEADER
is set, then instead of DUNNO
, we use PREPEND
, which will add a header to the email message, in this case, with the confidence score. A PREPEND
action has an implicit DUNNO
in there as well.
As a failsafe, if no client_address
line is sent, then the entire thing is skipped completely.
Postfix Integration
Really there’s two parts: adding it to Postfix’s config, and then giving Postfix a way to manage the process.
The first is simple, and is the check_policy_service
restriction, which I added like this to smtpd_client_restrictions
:
smtpd_client_restrictions = permit_mynetworks,
permit_sasl_authenticated,
reject_rbl_client zen.spamhaus.org,
reject_rbl_client bl.spamcop.net,
reject_rbl_client cbl.abuseat.org,
check_policy_service { unix:private/abuse_chk, default_action=DUNNO }
This means that, if nothing else has issued an OK
or REJECT
-like action, then the check_policy_service
will take effect, opening a UNIX socket at private/abuse_chk
, and sending the policy server data there.
If this doesn’t return correctly for some reason, then, by default, use the DUNNO
action.
Now, private/abuse_chk
is a named socket that’s relative to Postfix’s chroot, which lives at /var/spool/postfix
.
So somehow, I need to get the socket file /var/spool/postfix/private/abuse_chk
to exist, be readable and writable by Postfix, and have my program connected to the other end of it.
Luckily, Postfix itself can do that for us, with the master table.
Postfix Master Config
Ever peeked at master.cf
?
This is a table of every process that Postfix starts, and some specifics about them.
We just need to add two lines:
abuse_chk unix - n n - 0 spawn
user=nobody argv=/etc/postfix/abuseipdb-check
This instructs Postfix to spawn a service, named abuse_chk
, as a unix
service type, which, since it’s privacy is then unspecified (-
), it’s assumed to be yes
, meaning it places a UNIX socket for communications at /var/spool/postfix/private/abuse_chk
.
This also means that only the local Postfix process can access it.
After this, the first n
means that it’s not unprivileged.
Unprivileged processes run as the Postfix user, privileged run as root
.
The second n
means that the process is not chrooted to the mail queue.
The second -
means not to periodically wake up the service, since… we don’t need it.
That 0
means there are no limits on the number of running copies of this process.
And finally, spawn
is the actual command to execute, and below it, indented, are its arguments.
In this case, we’re spawning the actual process as nobody
(spawn
is who’s running as root
), and the process to spawn is /etc/postfix/abuseipdb-check
, which is that script above.
Since this means that Postfix will automatically allocate the named socket as we require, all we need to do it instruct Postfix to use it, as shown above.
The socket’s input is fed to the processes STDIN, and the process’s STDOUT is put back to the socket.
The only oddity here is that the Python print()
doesn’t work 100% as intended, you need to manually specify the output is sys.stdout
, but besides that, it’s nothing special.
Running This Yourself
Disclaimer: Be very careful, messing with Postfix delivery rules and restrictions can cause Bad Thingsā¢ to happen. However if you want to give it a try, I’ve cleaned up the script a little and stuck it on my local code hosting, you can grab the code from here (or just stick to the releases). The stuff in there is no longer just one script, since I moved all the configuration out of global variables into an INI file, but that README should tell you everything you need to know. If you want to have some more in-depth information on what’s happening, then you can check the wiki tab which is where I’m documenting the extras that are at play here, but aren’t required for knowing how to get it up and running.
Because I want to keep that instance closed (for now), instead of creating an issue the usual way, you can genuinely just email it and it should create one. Or, alternatively, sign-up with a Google or GitHub account is turned on, but direct registration can only happen if you already have an email address on my domain, which, you don’t.
-
if you check the man page for
access(5)
, you’d know there’s a few other results, like a specific error code, or to log a warning but let it continue. We’ll get to those in a second. ↩︎ -
421
and521
are special in Postfix: these indicate that you want it to disconnect immediately after giving the response, which is non-standard behavior. ↩︎