EXOTIC SILICON
“Still using spamd for greylisting? That's *so* 20 years ago!”
Exploring the filters API of OpenSMTPD and writing a greylisting filter
E-mailFiltersIn CWithJay Eptinxa
Today Jay is showing us how to interface with the native email filtering API of OpenSMTPD.
What better way to do that than by re-implementing some of the core functionality of spamd as a filter?
Intro
The default SMTP daemon in OpenBSD has been the native OpenSMTPD for about ten years now, and one of it's lesser promoted but still interesting features is a nice filter API. The minimalistic API syntax is really trivial to parse, so we can implement self-contained practical filters with just a few lines of tight and lean C source code. There is no need to resort to high level scripting languages that bring in a mountain of bloated third-party dependencies - the first demo filter that we'll see shortly weighs in at about 8K of simple C source code, much of which is comments, and it compiles to a binary of about the same size.
Since it's always more interesting to look at code that has an actual practical application rather than just programming things for the sake of it, I decided to take a project that has been at the bottom of my to-do list for some considerable time and use it as the basis of this tutorial.
Hey! Still using the default website theme? There are nine other themes to try out!
Re-implementing (some of) spamd
For a while now, I've wondered how practical it would be to implement the IP greylisting feature of spamd directly as an smtpd filter.
Many setups that have spamd enabled at all only operate it in a very basic configuration, and don't use any of the functionality that goes beyond simple IP greylisting on a single host. Whilst this works, (at least for IPv4 connections), the need for spamd to interact with the firewall configuration adding and removing hosts from a table as well as the need to run spamlogd so that already whitelisted hosts are not simply de-listed again, arguably seems like using a sledge hammer to crack a nut.
If greylisting was implemented as a small native smtpd filter then this complexity could potentially be avoided for setups that don't need it.
At this point, I must admit that I've never really liked the overall design of spamd and the way that it interfaces with pf. Nor do I particularly like the greylisting feature in general, because it can easily and subtly break genuine mail delivery.
Overall, the techniques implemented in spamd were fairly effective and served a clear and useful purpose twenty years ago, long before OpenSMTPD was the default MTA for OpenBSD. On a modern system in today's on-line environment, spamd looks and feels quite antiquated.
The slow-paced stuttering of connections, for example, achieves very little in an era when bandwidth is plentiful and a lot of junk email is being sent from networks of compromised machines. It's quite likely more irritating to me, wasting my time debugging genuine connection problems, than it is to any junk mail sender who might possibly never even be seeing or even less caring when individual smtp transactions fail.
However, one of the biggest practical issues with spamd today is that it doesn't really work correctly in an IPv6 environment. Especially not in a pure, IPv6 native environment like we have internally here at Exotic Silicon. For this reason alone, I've been interested in re-implementing it as a filter where IPv6 support could easily be included by default.
Breakage of genuine mail delivery can happen when mail is sent to a system running spamd from another system that operates a pool of different outgoing SMTP servers, all of which have different IP addresses. Since greylisting works on a per-IP basis, if each successive attempt to deliver the same mail comes from a different server then the system running spamd treats them as completely independent connections and will never whitelist any of them.
Even taking in to account the observations and pitfalls outlined above, implementing greylisting as an smtpd filter still seems like a useful project. After all, when spamd was introduced it was the only practical way to do this kind of filtering but with OpenSMTPD on the scene we now have more choice.
Overview of the filtering API
The filtering API is documented in the smtp-filters manual page. You will probably want to read that manual in conjunction with this article, but in essence all we are doing is passing pipe-delimited text data backwards and forwards over the standard input and output file descriptors.
So we receive lines such as:
report|0.6|1612092643.948548|smtp-in|link-identify|4eaf95da31b2f3d0|helo|smtp.example.com
filter|0.6|1612092643.948548|smtp-in|data|4eaf95da31b2f3d0|e599473df09ac96e
and we send back lines such as:
filter-result|0.6|1612092643.948548|smtp-in|link-identify|4eaf95da31b2f3d0|e599473df09ac96e|proceed
And that's really about as complicated as it gets.
Of course, we need some logic to keep track of different concurrent sessions and to actually do any useful filtering based on the content that we receive, but the point here is that the API communications protocol itself is just pipe-delimited text.
The filter program is started by smtpd and expected to continue running until smtpd itself terminates. If the filter terminates whilst smtpd is still running then this is considered an error condition and smtpd will exit.
As explained in the manual page, at startup the filters first receive some config lines from smtpd. In practice, most filters won't need to parse any of this information apart from waiting for the final config|ready line. At this point, the filter needs to indicate to smtpd which 'events' it wants to be notified of.
The list of required events to be reported will obviously depend on the nature of the filtering that the filter program is doing, although some events such as link-connect and link-disconnect will almost always be required in order for the filter to keep track of the currently active sessions.
Fun fact!
Timeouts
The manual page doesn't make absolutely clear whether a client that is disconnected due to a timeout will generate just a 'timeout' event or both a 'timeout' and a 'link-disconnect'. In fact, both events are generated, so unless we want our filter to take specific action in the case of a timeout, it's sufficient to just monitor 'link-disconnect' events for disconnection purposes.
After the initial configuration and setup phase, each line sent by smtpd and received by the filter represents an 'event'.
There are two types of event, 'report' events which are just informational and don't require a response from the filter, and 'filter' events which will cause smtpd to wait until a corresponding reply is received from the filter.
The format of the event lines is very consistent, even between the two types of event. From here on in this article I'll refer to the fields by their position with numbering beginning at zero, because this is the way that we'll reference them in the C code.
Field zero indicates the type of event, with a literal, 'report', or, 'filter'.
Fields one through to five are common between both types of event. The only difference here is in the documentation, which calls field four the 'event' field when talking about report events, and the 'filtering phase' field when talking about filter events. In practice, this is just two different names for the same thing, a field that indicates exactly which event we are receiving information about.
Field five is particularly important as this contains the session identifier. If smtpd is handling multiple smtp transactions at once, then the input to our filter will most likely consist of events from all of the sessions interleaved together. It's the responsibility of the filter to keep track of the state of each session and process input lines according to the appropriate one.
In the case of filter events, field six is also important as it's a token that needs to be passed back to smtpd along with the session ID. This mechanism helps to ensure that a buggy filter doesn't send erroneous data intended for one session in response to a different one. Report events don't include a token because they don't expect a response from the filter, so the token would have no purpose.
Implementation detail
Session identifiers and tokens
Strictly speaking, there is nothing in the documentation for OpenSMTPD that specifies a particular format for either the session ID, (field 5), or the opaque token, (field 6 in report events).
However, internally smtpd represents both of these values as uint64_t values, (see lka_filter.c, for example), and they are converted to and from a 16 digit hex representation when used with external filters, (see filter_protocol_query() in lka_filter.c).
It seems very unlikely that this representation will be changed, so the filter code presented in this article will assume that both session IDs and opaque tokens are fixed-length 16 byte strings. This will allow us to make some optimisations in the C code compared with processing these values as arbitrary length strings.
If you intend to write filters for widespread deployment on production systems and are concerned that this assumption might cause them to break in the future, it would be good practice to check either or both of the version numbers supplied by the API. The protocol version number is returned in field one of each event line, and the host smtpd version is returned as part of the configuration information sent by smtpd during the initial handshake. Checking these values is a much safer way to ensure complete API-level compatibility, rather than making attempts to second-guess what specific changes might happen in future releases.
Another detail to be aware of is that although the fields are pipe-delimited, in the last field, the pipe character is treated as a regular character and doesn't further delimit the line of input. Since different events have different total numbers of fields, knowing which field is the last field depends on knowing what sort of event we are processing. The upshot of this is that we can't simply apply the delimiting logic up to a particularly numbered field and then stop. In practice, though, this situation is very easy to deal with in C as we will discover shortly when we look at the code to implement the parser.
That concludes our brief look at the API protocol. For more details, refer to the smtpd-filters manual page.
A simple demonstration filter
The first step is to get the basic communication between our code and the smtpd filters API working and tested.
Although the logic we'll need for the IP greylisting filter isn't particularly complicated, since we're starting from scratch and don't have any code at all yet we might as well start with an even simpler filter that we can then use as a base for any future projects.
This filter will just look at the 'Date:' header of any emails that pass through it, and if the value of the header is a valid date and time in the standard format expected in email headers, the filter will normalise the timezone to UTC.
So if we provide the following date header:
Date: Sun, 27 Aug 2023 08:30:43 -1100
Then our filter will replace it with:
Date: Sun, 27 Aug 2023 19:30:43 +0000
Dates which don't strictly follow this format will be passed unaltered.
The actual date processing code is trivial and is not intended to be the main focus here. We could just as easily be inserting a completely new header, or changing the visible from address, or performing just about any other single-line change. The timezone normalisation idea does seem potentially useful, though, in the case of an employee working from a remote location and wanting to send mail out with the timezone of the main office.
In any case, to do this transformation we'll just call strptime() to de-construct the string to a timespec, and strftime() to construct a new printable date string after manually adding or subtracting the offset contained in it.
In terms of the smtpd filter API, the line we'll be reading in for processing will be something like:
filter|0.6|1612092643.948580|smtp-in|data-line|4eaf95da31b2f3d0|e599473df09ac96e|Date: Sun, 27 Aug 2023 08:30:43 -1100
And our output will be a single line in this format:
filter-dataline|4eaf95da31b2f3d0|e599473df09ac96e|Date: Sun, 27 Aug 2023 19:30:43 +0000
Development tools
We don't need anything special to write and compile this filter, but testing it during development by invoking it each time as a 'live' filter from smtpd can quickly become somewhat tedious.
A more convenient alternative, at least in the early stages of development, is to grab some sample output from smtpd and store it in a file so that we can just pipe it to our filter straight from the shell.
The following is typical output that we could use for this purpose, from a machine with hostname example.com:
config|smtpd-version|7.0.0
config|smtp-session-timeout|300
config|subsystem|smtp-in
config|admd|example.com
config|ready
report|0.6|1691754470.904005|smtp-in|link-connect|26ed94c90243382e|example.com|pass|unix:/var/run/smtpd.sock|unix:/var/run/smtpd.sock
filter|0.6|1691754470.905397|smtp-in|data-line|26ed94c90243382e|4ec085f750f16260|Received: from localhost (example.com [local])
filter|0.6|1691754470.905400|smtp-in|data-line|26ed94c90243382e|4ec085f750f16260| by example.com (OpenSMTPD) with ESMTPA id 4f562845
filter|0.6|1691754470.905402|smtp-in|data-line|26ed94c90243382e|4ec085f750f16260| for <somebody@example.com>;
filter|0.6|1691754470.905403|smtp-in|data-line|26ed94c90243382e|4ec085f750f16260| Fri, 11 Aug 2023 09:47:50 -0200 (-02)
filter|0.6|1691754470.905417|smtp-in|data-line|26ed94c90243382e|4ec085f750f16260|Date: Fri, 11 Aug 2023 09:47:50 -0200
filter|0.6|1691754470.905419|smtp-in|data-line|26ed94c90243382e|4ec085f750f16260|From: <>
filter|0.6|1691754470.905420|smtp-in|data-line|26ed94c90243382e|4ec085f750f16260|To: somebody <somebody@example.com>
filter|0.6|1691754470.905420|smtp-in|data-line|26ed94c90243382e|4ec085f750f16260|Message-ID: <ZNYt9px/CNYCnVL8@example.com>
filter|0.6|1691754470.905421|smtp-in|data-line|26ed94c90243382e|4ec085f750f16260|MIME-Version: 1.0
filter|0.6|1691754470.905422|smtp-in|data-line|26ed94c90243382e|4ec085f750f16260|Content-Type: text/plain; charset=us-ascii
filter|0.6|1691754470.905423|smtp-in|data-line|26ed94c90243382e|4ec085f750f16260|Content-Disposition: inline
filter|0.6|1691754470.905424|smtp-in|data-line|26ed94c90243382e|4ec085f750f16260|
filter|0.6|1691754470.905424|smtp-in|data-line|26ed94c90243382e|4ec085f750f16260|.
If this text is saved in a file named filter_dialogue, then we can test our filter code directly from the shell using redirection:
$ ./test_filter < filter_dialogue
Capturing sample output
If you want to capture output similar to the above from your own system, (which might be useful if you are using a different version of OpenSMTPD or have an unusual configuration), this can be done using a short shell script invoked as a primitive filter:
#!/bin/sh
echo "register|report|smtp-in|link-connect"
echo "register|filter|smtp-in|data-line"
echo "register|ready"
cat > /tmp/filter_dialogue
Obviously additional register lines can be added if you want to capture specific events, however note that any report events, (including the data-line one above), will cause the mail system to hang at that point since our shell script doesn't provide the expected replies.
If this script is placed in /usr/local/libexec/smtpd/test_filter, then a suitable reference to it can be added to /etc/mail/smtpd.conf like so:
filter test_filter proc-exec test_filter
Then assuming that your local mail program submits outbound email via a local socket:
listen on socket filter test_filter
Note that smtpd listens on a local socket by default, even if this isn't specified in the configuration file.
But if we want to pass data received via the socket to a filter then we need to explicitly specify this directive.
Curiosity
User, group and chroot options
By default, filters configured via proc-exec will run as the _smtpd user. The smtpd-filters manual page mentions that they can run as other users, but the actual configuration options necessary to do this are not stated.
Looking at the source code for smtpd, specifically for fork_filter_process() in smtpd.c, we can clearly see that code does exist to invoke filters as a custom user running with a custom group, and there is even code to support running filters in a chroot. Reading through parse.y, we find that proc-exec actually accepts the optional arguments user, group, and chroot.
None of these is documented or even mentioned in the syntax definition for proc-exec in the smtpd.conf manual page, although user and group do appear once in the examples section without further explanation.
These elusive options seemed to work as expected during testing, but obviously relying on such under-documented features for filters running on production systems is not ideal. For reference, they were added to the source code in 2018, (see smtpd.c revision 1.304, and parse.y revision 1.224).
If we wanted to run our test filter above as a dedicated user filter_user, we could use the following line in smtpd.conf:
filter test_filter proc-exec test_filter user "filter_user" group "filter_user"
Whilst the custom user and group are fairly easy to configure, getting a filter running in a chroot might cause a bit of frustration if you are not familiar with the requirements for setting up the chroot environment. If the chroot directory is not correctly populated with the required files, then smtpd will exit almost immediately with a fairly vague error message such as:
warn: lost processor: test_filter exited abnormally
Note that since fork_filter_process() uses the system() call to actually invoke the external filter program, a copy of /bin/sh will be required in $CHROOT/bin/sh in addition to any shared libraries that your filter itself uses.
To run the same filter in a chroot of /home/filter_user, we could specify:
filter test_filter proc-exec "/test_filter" user "filter_user" group "filter_user" chroot "/home/filter_user/"
Placing the filter shell script itself in /home/filter_user/test_filter, and copying /bin/cat to /home/filter_user/bin/ as well, since the script uses it. If the script is not modified to write it's output elsewhere, then the directory /home/filter_user/tmp will also need to be created.
Of course, new filters written specifically to run on OpenBSD systems might be better implemented using pledge() and unveil(), and avoiding the need to configure a chroot environment at all.
At this point, if we restart smtpd and try to send a regular email then the smtpd process will hang as noted above. This is because we've included a filter event in the list of events that our filter registers with, and when smtpd reaches this point it will be waiting for a response from the filter which will obviously never arrive.
However the output we wanted to capture will already have been written to /tmp/filter_dialogue, so we can now simply remove the lines we just added to smtpd.conf, and restart smtpd again to resume normal operation.
Input handling and buffering
Although we could use the stream I/O functions in the C standard library, the added level of abstraction that they provide doesn't really give us much benefit for the processing that we need to do in this situation so we might as well stick to the low-level file descriptor I/O calls.
Of course, when reading data from standard input in this way we need to do our own end of line parsing, since the read system call just returns arbitrary chunks of data and has no concept of 'lines'.
To deal with this, we read and store arbitrary amounts of data, (I.E. whatever comes back from the read() call), in to a fixed-size temporary buffer, (the raw buffer), noting the amount of valid data that we actually read.
We can read out exactly how much data we want from the raw buffer, looking for a newline character along the way, and store that data byte by byte in a separate line buffer. If no newline is found by the time we reach the end of the raw buffer, we simply re-fill it with another read() call, and continue transferring data to our line buffer.
Unless we encounter an error condition along the way, we will eventually find a newline character at which point our line buffer can be passed back to the calling function.
The following code implements this double buffering technique:
#define RAW_BUFFER_SIZE 65536
#define LINE_BUFFER_SIZE 4096
#define DEBUG_OUT(x) { write (STDERR_FILENO, x, sizeof(x)-1); }
int line_in(unsigned char * line_buffer, unsigned int * len, unsigned char * raw_buffer, unsigned int * raw_buffer_readpos, unsigned int * raw_buffer_writepos)
{
int bytesin;
unsigned int outpos;
outpos=0;
while (1) {
if (*raw_buffer_readpos==*raw_buffer_writepos) {
*raw_buffer_readpos=0;
bytesin=read(STDIN_FILENO, raw_buffer, RAW_BUFFER_SIZE);
/*
* If we get end of file, or an error reading stdin, just exit.
*/
if (bytesin==0) {
DEBUG_OUT ("Error - unexpected EOF on STDIN\n");
return(1);
}
if (bytesin==-1) {
DEBUG_OUT ("Error - got non-EOF error reading STDIN\n");
return(1);
}
*raw_buffer_writepos = bytesin;
}
/*
* If we have already reached the end of the output buffer then exit, as this shouldn't happen during normal operation.
*/
if (outpos==LINE_BUFFER_SIZE) {
DEBUG_OUT ("Error - buffer size exceeded\n");
return(1);
}
/*
* Read a character from the global buffer, and put it in the output buffer. If it's 10, null terminate and return, else loop.
*/
if ((*(line_buffer+outpos++) = *(raw_buffer+(*raw_buffer_readpos)++)) == 0x0a) {
*(line_buffer+outpos-1)=0;
*len=outpos-1;
return (0);
}
}
}
Note
The calling function is responsible for allocating memory for the two buffers as well as keeping track of the read and write pointers to the raw buffer.
In this particular application we only need to process a single input stream, but the line_in() function as written above would allow us to handle several independent streams concurrently simply by passing different sets of pointers.
Memory for the buffers is allocated using malloc_conceal() rather than regular malloc() to ensure that email content doesn't end up in core dumps, and that the buffer memory is zeroed out at program termination.
Note
We define our own DEBUG_OUT macro to write fixed-length strings to the standard error output rather than use dprintf.
None of the rest of the code requires us to include stdio.h and if we used dprintf for the debug code then it would add that dependency.
Note
The length of the line being returned is written to the supplied pointer len, but the line_in() function also null terminates line_buffer for convenience during debugging.
However the rest of the code will not rely on this null termination and will instead use the actual length returned. Although we wouldn't expect to encounter embedded 0x00 bytes in the data stream from smtpd, the filter shouldn't misbehave if it does so.
We can test the line_in() function above with the following code, which when run will just echo each new-line terminated line of input back to us rather like /bin/cat does when run interactively:
#include <stdlib.h>
#include <unistd.h>
int main()
{
unsigned char * line_buffer;
unsigned char * raw_buffer;
unsigned int readpos;
unsigned int writepos;
unsigned int len;
readpos=0;
writepos=0;
raw_buffer=malloc_conceal(RAW_BUFFER_SIZE);
line_buffer=malloc_conceal(LINE_BUFFER_SIZE);
while (line_in(line_buffer, &len, raw_buffer, &readpos, &writepos)==0) {
write (STDOUT_FILENO, line_buffer, len);
write (STDOUT_FILENO, "\n", 1);
}
}
Observe that we can read lines up to (LINE_BUFFER_SIZE - 1) bytes, (leaving one byte for zero termination), irrespective of the raw buffer size. We can even set the raw buffer size as low as one byte and the code still functions correctly. In practice, there isn't much point in setting RAW_BUFFER_SIZE greater than PIPE_SIZE as defined in /sys/sys/pipe.h, as this places an upper limit on the block size that will be used for such IPC.
Parsing the initial handshake
This first filter won't require any of the parameters that are sent by smtpd during the initial handshake. However we still need to read and discard the input up until we receive the config|ready line, at which point we can then reply with lines to register the events that we want to process.
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
int main()
{
unsigned char * line_buffer;
unsigned char * raw_buffer;
unsigned int raw_buffer_readpos;
unsigned int raw_buffer_writepos;
unsigned int line_buffer_len;
raw_buffer_readpos=0;
raw_buffer_writepos=0;
raw_buffer=malloc_conceal(RAW_BUFFER_SIZE);
line_buffer=malloc_conceal(LINE_BUFFER_SIZE);
line_buffer_len=0;
while (line_buffer_len != 12 || memcmp("config|ready", line_buffer, 12) != 0) {
if (line_in(line_buffer, &line_buffer_len, raw_buffer, &raw_buffer_readpos, &raw_buffer_writepos) != 0) {
DEBUG_OUT ("I/O error on STDIN whilst waiting for config|ready\n");
return (1);
}
}
}
Although the code doesn't require any definitions from time.h yet, it's included above since it's the only other system header file that will be needed.
The only events that we will monitor with this filter are the data-line and link-disconnect events, so our code to reply is just three lines:
write (STDOUT_FILENO,"register|filter|smtp-in|data-line\n",34);
write (STDOUT_FILENO,"register|report|smtp-in|link-disconnect\n",40);
write (STDOUT_FILENO,"register|ready\n",15);
A new connection will be implied when we see a session identifier that is not currently in use. This approach is sufficient for this first demonstration filter, and possibly for some very simple production filters, but In most other cases filters will want to register for the link-connect event to efficiently manage sessions.
Whilst it is also possible to infer the end and subsequent disconnection of a session from the end of the message body, there is virtually no benefit from doing so over using the intended link-disconnect report event and furthermore it would cause problems with sessions not being deleted if a client unexpectedly disconnects in the middle of the smtp data command.
Although filters intended for local deployment on trusted systems might be able to rely on this behaviour and operate reliably, any code that is intended for use on mailservers which process incoming connections from untrusted sources absolutely should not rely on such implied session disconnection, as it could potentially be mis-used by remote parties to cause undesired effects such as memory leaks and denial of service.
Parsing pipelines
At this point, smtpd will start to send us data in the pipe delimited format described above. Whilst we were able to recognise the config|ready line as a direct literal string, the rest of the output requires a more thorough and methodical approach.
From here on in this document, we will use the convenient term 'pipeline' refer to a line of pipe delimited output from smtpd, (or part thereof). Don't confuse this with any other use of the term 'pipe' such as in the context of IPC. Here, we are simply referring to a line of output from smtpd such as:
report|0.6|1691758070.904005|smtp-in|link-connect|26ed94c90243382e|example.com|pass|unix:/var/run/smtpd.sock|unix:/var/run/smtpd.sock
Before we look at the code, let's look at the structure that we'll use to hold the deconstructed pipeline data:
struct st_pipeline {
unsigned char * value[8];
unsigned int len[8];
unsigned int totlen[8];
unsigned int lastfield;
};
Here we have an array of pointers which will point to the first character of each field, along with an array of field lengths, and a simple integer counter of how many fields were actually present and therefore how many array elements contain valid data. But what about the totlen array? This will contain the total length of the current field as well as all subsequent fields, in other words how many bytes are left until the end of the line.
The main use for the total length field in this particular filter is when we need to repeat back lines of email content without any changes. Since we also need to send the session ID and opaque token back as well, we can just start reading from the beginning of field five, (the session ID), and carry on across all of the other delimiters, automatically including the contents of fields six and seven without having to think about them as separate entities.
This is one convenient advantage of programming in a language like C where we can easily access the raw data directly as a block of memory, rather than an abstraction of it. Typically in most higher level languages you would either end up doing something like field_5 + field_6 + field_7, having previously dealt with the complexity of identifying which numbered field was the last one, (given that it may include pipe characters which are not delimiters), or alternatively doing something like field_5 + field_6 + field_7 ... field_n, where n is the last field, if you'd decided to avoid that complexity in the pipeline parsing code and just split the raw line in to fields at every pipe, with the intention of joining back the extra trailing fields that had been created afterwards.
None of that additional processing is necessary with the approach presented here.
When used in more complex filters, the total length field may also have other potential uses of a similar nature.
In the structure detailed above we have set a maximum of eight fields, and the version of our pipeline parsing code for this filter will be hard-coded to ignore any delimiters after field seven. Field seven just happens to be the last field of the data-line event, in which we want to interpret the pipe character as a literal anyway.
We can make this small optimisation here because we won't be processing any event lines which contain more than eight fields - the link-disconnect event ends at field five. Currently, as of OpenSMTPD 7.0.0, no events will create more than eleven fields anyway, although this could change in future versions of the API.
The following code parses a supplied raw pipeline as received by line_in() and fills in the supplied struct st_pipeline:
int parse_pipeline(unsigned char * buffer_line_in, unsigned int buffer_line_in_len, struct st_pipeline * pipeline) {
unsigned int i;
unsigned int pipecount;
pipecount=0;
memset(pipeline, 0, sizeof(struct st_pipeline));
if (buffer_line_in_len==0 || *buffer_line_in==0) {
return (1);
}
pipeline->value[0]=buffer_line_in;
for (i=1; i<buffer_line_in_len && pipecount < 7; i++) {
if (*(buffer_line_in+i)=='|') {
pipeline->len[pipecount]=i-(pipeline->value[pipecount]-buffer_line_in);
pipeline->totlen[pipecount]=buffer_line_in_len-(pipeline->value[pipecount]-buffer_line_in);
pipecount++;
pipeline->value[pipecount]=buffer_line_in+i+1;
}
}
pipeline->len[pipecount]=buffer_line_in_len-(pipeline->value[pipecount]-buffer_line_in);
pipeline->totlen[pipecount]=buffer_line_in_len-(pipeline->value[pipecount]-buffer_line_in);
pipeline->lastfield=pipecount;
return (0);
}
Coding detail
Size of a struct
Note that we take the size of struct st_pipeline for the call to memset, which will be correct and take in to account the array dimensions as they are part of the struct definition.
Common mistakes are to do something like sizeof(pipeline), which in this case will give the size of one pointer to a struct st_pipeline, (so typically 8 bytes on a 64-bit architecture).
Also, if we had an array of struct st_pipeline, then we would multiply the sizeof by the number of elements, but we don't have an array here as the only arrays are contained within the struct itself.
The parse_pipeline() function is fairly straightforward. First we zero out the entire struct st_pipeline with a call to memset, then we check for the supplied line being an empty string, which should not happen during normal operation. If we do encounter either zero-length input or input which begins with a null despite a non-zero length parameter being supplied, then we return immediately with 1 to indicate the error.
(Although the code is intended to support data containing embedded nulls, finding a null as the first byte would almost certainly indicate a logic error elsewhere such as being passed the wrong pointer value, so we treat this condition as an error.)
Since parse_pipeline() writes valid data to as many array elements as required and returns this value in pipeline->lastfield, zeroing out the entire structure is not strictly necessary if we have at least one field to process and if the calling function checks the value of pipeline->lastfield before accessing any array elements beyond zero.
However, since pipeline->lastfield is set to the index of the last valid field rather than the total number of fields, (in other words, it contains the number of valid fields minus one), zeroing out at least the first element of the arrays is important if the supplied input is empty, because simply returning the minimum value of zero for pipeline->lastfield implies that a single field with index 0 is valid. Empty input is considered as a single field with zero length, so in this case setting the length elements to zero is necessary.
By simply zeroing the whole structure each time, we also ensure that null input will return a null pointer. This should in turn cause the calling code to crash with a segfault in the case of a logic error there causing the pointer to be de-referenced, rather than have it erroneously process stale data from a previous invocation of parse_pipeline().
In any case, empty input lines are flagged as an error condition and processing should either completely ignore such input or terminate the filter.
Pedantic note:
Truly portable C code shouldn't assume that doing a memset of 0x00 bytes on a pointer will set it equal to NULL.
However in practice it is very unusual to encounter an architecture where this is not the case.
Additionally, the calling function is responsible for parsing only those fields which are indeed valid, and the intended way for it to ensure this is to check the value of pipeline->lastfield before accessing the arrays. However, by ensuring that non-existent fields have their length values set to zero the calling code can also just simply perform a check for the string it's expecting which will fail to match in the case of an empty, (zero-length), field.
(In fact, this is the method we will use in the main filter code.)
The pointer to field zero is set explicitly to the beginning of the supplied line, then we loop through the rest of the input byte-by-byte looking for pipe characters as we go. Each time we find one we calculate the length of the current field, increase the field count and set a pointer to the start of the new field.
The loop ends when we reach the end of the input, or when we get to field seven, whichever comes first. Outside the loop we fill in the length of the final field and the total field count.
At that point, we've finished parsing the line of raw input and have the data we need nicely stored in a struct st_pipeline.
Fun fact!
Trailing zero-length fields
If the supplied input pipeline ends with a pipe character then parse_pipeline() will correctly interpret this as a trailing field of zero length, and fill in a corresponding array entry. In this case, the pointer address in pipeline->value[n] will be one byte after the end of the input.
The calling function shouldn't try to access this address anyway if the field length is zero, but even if it does the fact that line_in() adds an, (otherwise un-needed), terminating null byte to the line buffer ensures that we won't read unwritten memory if we do in fact dereference the final pipeline->value[n] pointer.
Demonstration code to invoke parse_pipeline()
The following function calls parse_pipeline() with a pipe delimited string, then reads out the data from the struct st_pipeline arrays:
#include <stdio.h>
int demo_parse_pipeline()
{
unsigned int i;
struct st_pipeline pipeline;
parse_pipeline("zero|one|two|three|four|five|six|seven|eight|nine", 49, &pipeline);
printf ("Done parsing last field is %d\n", pipeline.lastfield);
for (i=0; i<=pipeline.lastfield; i++) {
printf ("%d %p %u %d %s\n", i, pipeline.value[i], pipeline.len[i], pipeline.totlen[i], pipeline.value[i]);
}
return (0);
}
Note:
The demo code above requires the stdio.h header file for the formatted print output, but the rest of the filter code won't.
Running it will produce output similar to the following, although obviously the pointer values will be different for each run:
Done parsing last field is 7
0 0xa41f639a5c0 4 49 zero|one|two|three|four|five|six|seven|eight|nine
1 0xa41f639a5c5 3 44 one|two|three|four|five|six|seven|eight|nine
2 0xa41f639a5c9 3 40 two|three|four|five|six|seven|eight|nine
3 0xa41f639a5cd 5 36 three|four|five|six|seven|eight|nine
4 0xa41f639a5d3 4 30 four|five|six|seven|eight|nine
5 0xa41f639a5d8 4 25 five|six|seven|eight|nine
6 0xa41f639a5dd 3 20 six|seven|eight|nine
7 0xa41f639a5e1 16 16 seven|eight|nine
So far, so good - we have pointers to each field, and suitable length values to read either one or all remaining fields.
Session management
With the pipeline processing code in place, the next step is to handle session management.
In more complicated filters where we need to store various pieces of metadata associated with each session, it would obviously be beneficial to define a C structure for this purpose.
However, all this current filter does is to re-write one header line, so we only need to store the session ID along with a single flag telling us whether we are still within the headers, (in which case we make the transformation when we see a line that matches our criteria), or have finished the headers and are now parsing the body text, (in which case we never match any lines, but just pass them back unaltered).
If we were going to define a structure for this, it would probably look something like the following:
struct st_session {
unsigned char sid[16];
unsigned int flag;
};
However, we can also just as easily store flat 17-byte records in memory directly and access them as raw bytes.
Doing so doesn't provide any technical advantage, in fact by using a struct and the features of the C language for handing this type of data, the compiler would almost certainly do some optimisation for us by appropriately aligning the members of the struct to improve average memory access times.
On the other hand, it is a convenient opportunity for such a programming exercise, given that the data we want to store is trivial. Seeing how it's done without recourse to a C struct is useful in case we ever want to implement something similar in assembler where we don't have high-level features such as structures.
The memory for the sessions table will be allocated early in the main() function and never free'd. We also set a counter of currently active sessions to zero:
#define MAX_SESSIONS 1024
#define SR_SIZE 17
int main()
{
unsigned char * session_table;
unsigned int sessions;
session_table=malloc(MAX_SESSIONS * SR_SIZE);
sessions=0;
return (0);
}
Data format:
In memory, each record is 17 bytes long, (SR_SIZE), and stored as follows:
  • 16 bytes of ASCII characters for the session ID, without a terminating null.
  • 1 byte of memory that this session can use.
The following code defines a new function session_get_add(), which accepts pointers to the two values we initialized above, as well as a pointer to the session ID to check or add, and a double pointer to a single byte of memory that the calling function can use to persistently store the required information about this session. In this case, the data is simply the one flag mentioned above.
int session_get_add(unsigned char * session_table, unsigned int * sessions, unsigned char * sid, unsigned char ** data_area)
{
unsigned int n;
for (n=0; n<*(sessions); n++) {
if (memcmp(sid, session_table+(n * SR_SIZE), 16)==0) {
*data_area=session_table+(n * SR_SIZE + 16);
return (0);
}
}
/*
* No existing entry matching the session ID and token was found, so we add a new one.
*/
if (*(sessions) == MAX_SESSIONS) {
return(2);
}
*data_area=session_table+(n * SR_SIZE + 16);
memcpy(session_table+(n * SR_SIZE), sid, 16);
(*sessions)++;
return (1);
}
The use of a double pointer is simply so that we can return the, (normal), pointer value to the calling function.
We don't zero out or clear the data byte in any way here, the calling function is responsible for setting it at the start of a new session. If we were ever to expand the data area to store more data, especially sensitive data, then this session_get_add() function should perform such initialisation.
Testing the session_get_add() function is easy enough:
#include <stdio.h>
int main()
{
unsigned char * session_table;
unsigned char * data_area;
unsigned int sessions;
session_table=malloc(MAX_SESSIONS * SR_SIZE);
sessions=0;
session_get_add(session_table, &sessions, "0123456789ABCDEF", &data_area);
printf ("%d %p\n", sessions, data_area);
session_get_add(session_table, &sessions, "0123456789ABCDEF", &data_area);
printf ("%d %p\n", sessions, data_area);
session_get_add(session_table, &sessions, "0101010101010101", &data_area);
printf ("%d %p\n", sessions, data_area);
session_get_add(session_table, &sessions, "0123456789ABCDEF", &data_area);
printf ("%d %p\n", sessions, data_area);
session_get_add(session_table, &sessions, "0123456789ABCDEF", &data_area);
printf ("%d %p\n", sessions, data_area);
session_get_add(session_table, &sessions, "0000000000000000", &data_area);
printf ("%d %p\n", sessions, data_area);
return (0);
}
The output from the test code above will be something like this:
1 0xb3368c9b010
1 0xb3368c9b010
2 0xb3368c9b021
2 0xb3368c9b010
2 0xb3368c9b010
3 0xb3368c9b032
So we can see that each unique session ID supplied to session_get_add() returns a different pointer, that the total number of active sessions increases when a new and previously unseen session ID is supplied, and that existing sessions match the pointer previously returned for them without increasing the total session count.
Implementation detail
Opaque token
Current versions of OpenSMTPD generate the opaque token once per session, (refer to lka_filter_ready() in lka_filter.c), so every event for a particular session returns the same token.
However, once again, there is nothing in the documentation that actually guarantees this behaviour. Whilst we could currently treat the session ID and opaque token as a single unit in our session management code, the correct way is to track only the session ID itself, and when we reply to a filter event to always return the token that was actually supplied that time.
To delete sessions we just need to search for the supplied session ID in the table and move any following entries back one slot:
int session_delete(unsigned char * session_table, unsigned int * sessions, unsigned char * sid)
{
unsigned int n;
for (n=0; n<*(sessions); n++) {
if (memcmp(sid, session_table+(n * SR_SIZE), 16)==0) {
if (n==((*sessions)-1)) {
(*sessions)--;
return (0);
}
memcpy(session_table+(n * SR_SIZE), session_table+((n + 1) * SR_SIZE), ((*sessions) - n - 1) * SR_SIZE);
(*sessions)--;
return (0);
}
}
return (1);
}
At this point we have working session management and can move on to the actual filtering code.
Main filtering code
The bulk of the main() function is a large while loop which only terminates on encountering an error condition from line_in().
We also have some more variable declarations and memory allocation for another buffer, which can go at the start of main().
unsigned char * ascii_time_buffer;
unsigned int flag_skip_line;
unsigned int ascii_time_buffer_len;
int offset;
struct tm time_tm;
time_t timestamp;
ascii_time_buffer=malloc(128);
while (line_in(line_buffer, &line_buffer_len, raw_buffer, &raw_buffer_readpos, &raw_buffer_writepos)==0) {
flag_skip_line=0;
parse_pipeline(line_buffer, line_buffer_len, &pipeline);
if (pipeline.len[4]==15 && memcmp(pipeline.value[4], "link-disconnect", 15)==0) {
session_delete(session_table, &sessions, pipeline.value[5]);
}
if (pipeline.len[4]==9 && memcmp(pipeline.value[4], "data-line", 9)==0) {
result=session_get_add(session_table, &sessions, pipeline.value[5], &data_area);
if (result==2) {
DEBUG_OUT ("Maximum number of concurrent sessions reached\n");
return (1);
}
/*
* If this is a new session, then set the 'within headers' flag.
*/
if (result==1) {
*data_area=1;
}
if (pipeline.totlen[7]==0) {
*data_area=0;
}
if (*data_area==1 && pipeline.totlen[7] > 5 && memcmp(pipeline.value[7], "Date:", 5) == 0 ) {
/*
* Skip extraneous spaces and tabs.
* These will be collapsed to a single space in the output if we modify the date header.
*/
for (i=5; i < pipeline.totlen[7] && (*(pipeline.value[7]+i)==' ' || *(pipeline.value[7]+i)==9); i++) { }
if (i < pipeline.totlen[7] && strptime(pipeline.value[7]+i, "%a,%e %b %Y %H:%M:%S %z", &time_tm)!=NULL) {
flag_skip_line=1;
offset=(time_tm.tm_gmtoff);
timestamp=timegm(&time_tm);
timestamp-=offset;
time_tm=*gmtime(&timestamp);
ascii_time_buffer_len=strftime(ascii_time_buffer, 128, "%a,%e %b %Y %H:%M:%S %z", &time_tm);
write (STDOUT_FILENO, "filter-dataline|", 16);
write (STDOUT_FILENO, pipeline.value[5], 34);
write (STDOUT_FILENO, "Date: ", 6);
write (STDOUT_FILENO, ascii_time_buffer, ascii_time_buffer_len);
write (STDOUT_FILENO, "\n", 1);
}
}
if (flag_skip_line==0) {
write (STDOUT_FILENO, "filter-dataline|", 16);
write (STDOUT_FILENO, pipeline.value[5], pipeline.totlen[5]);
write (STDOUT_FILENO, "\n", 1);
}
if (pipeline.len[7] == 1 && *pipeline.value[7]=='.') {
*data_area=1;
}
}
}
return (1);
We also need to increase the value of 4096 that we previously used for LINE_BUFFER_SIZE earlier during testing, as smtpd will accept smtp data lines up to 65536 bytes, (refer to SMTP_LINE_MAX in smtp_session.c). A value of 66560 allows sufficient space for a 'data-line' event with such a long final field.
#define LINE_BUFFER_SIZE 66560
Any event other than data-line or link-disconnect is ignored. Note that we check for 'data-line' and 'link-disconnect' in field four using memcmp with an explicit length value rather than strcmp, as the fields are not null-terminated.
Pedantic note:
A fully API-compliant filter should probably also check that field zero contains the corresponding literal, 'report', or, 'filter'.
Link-disconnect events just result in a call to session_delete() to remove the supplied session ID from the sessions table.
For each data-line event that is received, the code first calls session_get_add() to look up the session ID in the existing sessions table and add it if it isn't already there. In the case of a new session, the flag is then immediately set to indicate that we are processing the headers and should therefore make appropriate substitutions on any lines that look like date headers until we encounter the blank line which signals the end of the email headers and the beginning of the email body text.
This flag is reset when the code sees a data-line event with field seven set to zero length, in other words a blank line.
The code to check for a date header and actually do the timezone adjustment is only run at all if the 'within headers' flag is set.
If the current email content line begins with 'Date:', then we first skip any spaces and tabs following 'Date:', and pass the rest of the line to strptime(). If strptime() returns a valid broken down time in time_tm then we set a flag to suppress direct copying of the input to the output for this line of the email and proceed to further process the time_tm value that we have according to the algorithm described below. The result is then written to standard output along with the session ID and opaque token.
Note that we read 34 characters from field five in the following call to write(), even though field five itself should only contain the 16 characters of the session ID. This technique effectively reads field six at the same time and includes the trailing pipe character after each of those two fields, avoiding the need for us to manually insert it.
Lines in the email content which don't begin with 'Date:' are simply passed back to smtpd, and here we use the pipeline.totlen value to read everything from field five onwards in one go. So with a single call to write() we can copy out the session ID, opaque token, line of the email, and the necessary pipe delimiters.
Finally, if we see a line with the single terminating period then we reset the 'within headers' flag. This is necessary to ensure that when multiple emails are sent in a single session that the headers for each one are parsed. Without the reset of this flag, only the first email would have it's date header modified.
At this point, the code is functional and can be compiled and installed in /usr/local/libexec/smtpd for use as a filter.
Finishing touches
For completeness we could also add a call to pledge, allowing just the stdio promise:
pledge ("stdio", NULL);
Time conversion algorithm overview
The actual algorithm used to normalise the timezone might not be entirely obvious to readers unfamiliar to the various time-handling functions and representations of time used on OpenBSD, (and other BSD systems).
In this case, the following explanation should de-mystify it:
Adding or subtracting the number of hours, (and possibly minutes), that is specified in the timezone offset would be easy if it wasn't for the fact that passing midnight in either direction, (forwards or backwards), can make the day, month, and even year change as well.
As a result of this, we need to fully parse the textual date format in order to adjust it. In this case, we convert it in to a linear representation to which we can add arbitrary amounts of time, apply the timezone delta, then convert the linear representation back to text.
Since the intention here is to demonstrate the smtpd filters api rather than create a filter for production use, the example code to implement this algorithm has deliberately been kept simple at the expense of portability, (more details on this below).
Specifically, we use the following steps:
  1. strptime() converts the textual format in to a struct tm representation, commonly known as broken down time.
  2. we store the timezone offset from the tm_gmtoff field in offset.
  3. timegm converts the struct tm representation to a time_t value, ignoring the tm_gmtoff field.
  4. we then subtract the offset that we stored earlier
  5. gmtime then converts the time_t value back to a struct time_tm, setting the tm_gmtoff field to zero.
  6. strftime converts the struct time_tm representation back to text.
As a worked example, take the string: Thu, 1 Jan 1970 01:05:00 +0100.
The broken down time values produced by strptime() are shown in the table.
tm_sec0tm_min5
tm_hour1tm_mday1
tm_mon0tm_year70
tm_wday4tm_yday0
tm_isdst0tm_gmtoff3600
tm_zone(null)
We save the offset of 3600 seconds in, 'offset'.
The time_t value returned by timegm() is 3900, which is consistent with one hour, (60 × 60 = 3600), plus five minutes, (3600 + 300 = 3900), in other words the raw time value disregarding the timezone offset.
We perform the subtraction in step four: 3900 - 3600 = 300
tm_sec0tm_min5
tm_hour0tm_mday1
tm_mon0tm_year70
tm_wday4tm_yday0
tm_isdst0tm_gmtoff0
tm_zoneGMT
In step five, gmtime() converts the value 300 in to the broken down time values shown in the second table.
Finally, strftime() produces the string: Thu, 1 Jan 1970 00:05:00 +0000, which is what we wanted.
Important!
Portability
The above timezone conversion process works on OpenBSD, but makes certain assumptions that might not be true on other systems.
In practice, the main issue is the use of strftime() to convert the modified broken down time back to a textual representation. When running on OpenBSD we can assume that this function will always use the C locale, producing output suitable for use in the email date header. Implementations on other systems might use different locales, and produce localised output which is not suitable. This issue is recognised within smtpd itself, which defines it's own function time_to_text() in to.c to avoid calling strftime(). We could easily do the same thing here if we wanted to use this filter in conjunction with the portable version of OpenSMTPD running on a system other than OpenBSD.
The tm_gmtoff field of struct tm is not mandated by POSIX, but it's rare to find a modern system which doesn't include it. Although the ctime manual page notes that this 'non-standard' field might change or be removed in the future, this advisory has been in the manual page for about 30 years so it seems fairly safe to assume that these fields won't change without widespread consensus.
Truly portable C code shouldn't even assume that subtracting an offset in seconds from a time_t value will produce the expected time value, although POSIX compliant systems do allow this assumption.
All of these portability issues could easily be fixed by writing a short custom function to do the manipulation of the date string directly instead of relying on the system library functions.
A greylisting filter
Now that we have the basic functionality to communicate with smtpd via it's filter API, we can move on to something more interesting - the greylisting filter.
The greylisting functionality implemented in spamd is based around data structures that the spamd documentation refers to as tuples, each of which is nothing more than a set of four data items, specifically: source IP, host identification, envelope sender, and envelope recipient.
If the same host tries to send mail to the same recipients, (and from the same sending address), more than once within a set time limit, then the transaction is recognised by spamd as matching one which has already been seen before. Based on the elapsed time between attempts it may decide that the next attempt should be passed through to the real smtp server, and will update the firewall rule table accordingly.
Our filter will use a broadly similar approach, with the main difference being that once an smtp session passes the requirements for being whitelisted then it will simply be allowed to continue. This is in contrast to the approach taken by spamd, which only immediately updates it's internal database, (usually held in /var/db/spamd), when a session creates or updates a tuple. Separately spamd scans this database periodically, (by default every 60 seconds), for entries which should then be whitelisted, and the firewall is asynchronously updated ready for the next smtp connection attempt by any newly whitelisted host.
Being able to take the more direct approach of just letting the existing session continue is one advantage of implementing this functionality directly as a filter for smtpd.
Implementation detail
Persistent vs volatile database storage
Spamd stores it's database on disk, which allows greylisted and whitelisted IPs to persist over restarts of spamd itself.
In contrast to this approach, our filter will hold it's tuple database only in RAM. This means that a restart of the filter, (and so by extension, a restart of smtpd), will erase the current greylisted and whitelisted entries.
This is a deliberate design decision, based on various considerations:
Writing each entry to the filesystem as it is created, whilst possible, seems excessive.
However, in some cases, persistence of the database over clean and planned restarts of smtpd might be desired. We'll see later how to add code to write the in-memory database to disk on filter shutdown and re-load it at filter start-up with very little added complexity or performance loss.
Data structures
The greylisting filter will use an almost identical version of struct st_pipeline to that used by the demonstration filter, it will just be expanded to hold a maximum of eleven fields instead of eight.
We'll also define two new C structures, one for session data, (for which the previous filter just wrote directly to a block of memory), and one for tuples.
struct st_pipeline
#define MAX_FIELDS 11
struct st_pipeline {
unsigned char * value[MAX_FIELDS];
unsigned int len[MAX_FIELDS];
unsigned int totlen[MAX_FIELDS];
unsigned int lastfield;
};
struct st_session
struct st_session {
unsigned char sid[16];
unsigned char ip[48];
unsigned char * helo;
unsigned char * env_from;
unsigned int ip_len;
unsigned int helo_len;
unsigned int env_from_len;
};
struct st_tuple
struct st_tuple {
unsigned char ip[47];
unsigned char flag_ok;
unsigned char * helo;
unsigned char * env_from;
unsigned char * env_to;
unsigned int ip_len;
unsigned int helo_len;
unsigned int env_from_len;
unsigned int env_to_len;
time_t timestamp;
};
The IP address fields in struct st_session and struct st_tuple can potentially require a maximum of 47 bytes if they need to store an IPv6 address with the full 32 hex digits and a five digit decimal port number:
[xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx]:12345
32 bytes for hex digits
7 bytes for colon separators between hex digits
2 bytes for square brackets
1 byte for the colon separator between address and port
5 bytes for the port number
Total: 47 bytes
Note that we deliberately place the flag_ok element immediately after the ip[] element within the structure. The compiler will typically align larger elements to byte boundaries that fall on some power of two, (typically eight bytes), and will also pad the end of the structure to a similarly rounded size. Since flag_ok is the only one-byte value, this means that any other ordering of the elements would be likely to create one byte of padding immediately following ip[] and increase the overall size of struct st_tuple by eight bytes.
Although it doesn't really make much sense to use this filter on connections received on a local socket, a size of 47 bytes is also sufficient to store the default socket address string, 'unix:/var/run/smtpd.sock'. Since the path to this socket can only be changed by modifying the source code to smtpd and re-compiling, the 47 byte size for ip[] is effectively sufficient to store any connecting address that might currently be encountered when running with an unmodified smtpd.
Three fields are common between st_session and st_tuple. As an smtp session progresses, the values for ip, helo, and env_from are filled in one-by-one in the corresponding session entry. Once all three of those have all been received, if a recipient address follows then a tuple is constructed from the values in the session table along with that recipient address. An entry in the struct st_tuples array will be either created if it doesn't already exist, or updated as necessary if it does already exist.
The timestamp in struct st_tuple will be set once when a particular tuple is first seen, and then not updated whilst it is greylisted. If the tuple is eventually whitelisted then this timestamp will be updated whenever it is seen again, until it expires.
The flag_ok value holds a value of 0 for greylisted, and 1 for whitelisted.
The greylisting filter will follow the same general program structure as the first demonstration filter, but we obviously need some additional functions to implement the new functionality and there will also be some changes to all but one of the existing functions.
Input, buffering and line parsing
The line_in() function that we used for the first demonstration filter can be used unchanged.
In parse_pipeline(), we previously hard-coded a value of 7 as the maximum number of pipe characters to interpret as delimiters, giving us up to eight fields, (numbered zero to seven). This won't be enough for the greylisting filter as we need to process link-connect and tx-rcpt events which create ten and nine field responses respectively.
Since the updated struct st_pipeline definition moves the size of the arrays in to #define MAX_FIELDS, we can use MAX_FIELDS - 1 in the loop termination condition instead of a constant:
for (i=1; i<buffer_line_in_len && pipecount < MAX_FIELDS - 1; i++) {
This is the only required change to parse_pipeline().
Debugging and logging code
The previous filter didn't require much in the way of diagnostic output during development, nor was there really any point in creating log files during normal use. Since we only needed to output simple fixed strings and didn't need to include stdio.h for any other purpose it was convenient to just define the DEBUG_OUT macro as a direct call to write().
In contrast, the greylisting filter will have various internal structures that it will be useful to examine during both initial development and regular use. Since this diagnostic output will require formatting of numerical values, we'll use the printf family of functions and so the code will need to include stdio.h this time.
Whilst testing the filter code from the shell, we can conveniently write such output to the standard error channel for viewing immediately on the terminal. We could also use this mechanism when the filter is invoked by smtpd, since in this case the standard error output is recorded in /var/log/maillog. However, although this is acceptable for the odd diagnostic message, it can become tedious to have large amounts of filter debugging data interleaved with unrelated messages from smtpd. Instead, we'll write the log to it's own file.
To do this, we just need to open the logfile for writing near the beginning of program initialisation in main() and pass it's file descriptor to any functions that might need to write log entries.
Actually writing the log entries can be done easily enough with dprintf. For example, near the beginning of main() we'll have code like this:
int fd_logfile;
[...]
fd_logfile=open("/path/to/logfile", O_WRONLY | O_CREAT | O_TRUNC, 0600);
[...]
raw_buffer=malloc_conceal(RAW_BUFFER_SIZE);
if (raw_buffer == NULL) {
dprintf (fd_logfile, "Memory allocation error\n");
return (1);
}
Technique
Log file location and access flags
During development, it's quite possible that the filter will be invoked as both your own user when testing from the shell, and by the default _smtpd user when testing in conjunction with smtpd, (if no custom user has been specified for the proc-exec option as discussed earlier).
Although /tmp might be a convenient choice of location for the logfile at least during development, as this is the only location that both users are likely to have write access to, if a logfile owned by the other user already exists when smtpd is invoked then the call to open() will fail.
Depending on your workflow, the easiest solution might be simply to remember to unlink any exising logfile when switching between the two users for testing, or otherwise to write to separate logfiles depending on which user the filter is running as.
Alternatively, creating a dedicated user account for the filter and using this user for both direct testing from the shell as well as the invocation via smtpd allows a single logfile location to be used in both cases.
Whilst developing and debugging the code it's often useful to have the logfile erased on each run, which is why the example above includes the O_TRUNC flag. Once the filter is ready for deployment on production systems, this can be replaced with O_APPEND and the logfile appended to in the normal way.
Initial handshake parsing
Technically, we don't actually need to make any changes to the code that parses the initial API handshake.
However as mentioned earlier, filters intended for production use should really check at least one of either the smtpd version number or the API protocol version to reduce the possibility of processing data erroneously due to future changes or additions to the API.
Unlike the protocol version number which is simply field one in each response line received, the smtpd version number is returned only once during the initial handshake. We might as well take the opportunity to add code to extract this value for later parsing:
unsigned char * host_version;
host_version=NULL;
while (line_buffer_len != 12 || memcmp("config|ready", line_buffer, 12) != 0) {
if (line_in(line_buffer, &line_buffer_len, raw_buffer, &raw_buffer_readpos, &raw_buffer_writepos) != 0) {
dprintf (fd_logfile, "I/O error on STDIN whilst waiting for config|ready\n");
return (1);
}
if (line_buffer_len > 21 && memcmp("config|smtpd-version|", line_buffer, 21) ==0) {
host_version=malloc(line_buffer_len-20);
if (host_version == NULL) {
dprintf (fd_logfile, "Memory allocation error\n");
return (1);
}
memcpy(host_version, line_buffer+21, line_buffer_len-21);
*(host_version+line_buffer_len-21)=0;
}
}
Here, host_version defaults to being a NULL pointer but is set to point to a null-terminated string containing the smtpd version number if the config|ready line is seen during the initial handshake. This can then be parsed as necessary to meet the requirements of the particular filter.
Exactly how to interpret the individual digits of the smtpd version number and what action to take if it is higher or lower than expected is an implementation detail for each specific filter.
The list of events that we'll monitor is also slightly longer than before:
write (STDOUT_FILENO,"register|report|smtp-in|link-connect\n",37);
write (STDOUT_FILENO,"register|report|smtp-in|link-disconnect\n",40);
write (STDOUT_FILENO,"register|report|smtp-in|link-identify\n",38);
write (STDOUT_FILENO,"register|report|smtp-in|tx-mail\n",32);
write (STDOUT_FILENO,"register|report|smtp-in|tx-rcpt\n",32);
write (STDOUT_FILENO,"register|filter|smtp-in|data\n",29);
write (STDOUT_FILENO,"register|ready\n",15);
Session management, (again!)
The session management code of the greylisting filter will differ in two main ways from the implementation that we saw earlier:
Other than that, the session handling is quite similar.
Here is the new version of session_get_add():
int session_get_add(struct st_session * session_table, unsigned int * sessions, unsigned char * sid, unsigned int * session_entry)
{
unsigned int n;
for (n=0; n<*(sessions); n++) {
if (memcmp(sid, session_table[n].sid, 16)==0) {
*session_entry=n;
return (0);
}
}
/*
* No existing entry matching the session ID and token was found, so we add a new one.
*/
if (*(sessions) == MAX_SESSIONS) {
return(2);
}
memcpy(session_table[*sessions].sid, sid, 16);
/*
* Set pointers to NULL so that we know whether to free them later or not.
* Other values can be left wild.
*/
session_table[*sessions].helo=NULL;
session_table[*sessions].env_from=NULL;
*session_entry=*sessions;
(*sessions)++;
return (1);
}
The first three arguments supplied have not changed, but the last argument is now a simple unsigned integer pointer which is used to return the array index of the session matching the supplied session ID to the calling function. Previously this argument was an indirect pointer to a single byte of memory.
As before, session_get_add() returns 0 on finding an existing matching entry in the session table, 1 if a new entry was added, or 2 if the sessions table is full.
Note that we should not reach MAX_SESSIONS in normal operation, if it is set higher than the maximum number of simultaneous sessions that smtpd itself would allow. Although we could add the facility to dynamically re-size the sessions table, since reaching MAX_SESSIONS here indicates either a code logic or configuration error we instead return with the value two, which the calling function should interpret as an error condition and then cause the filter to terminate.
When a new session ID is added to the table, the pointer elements are set to null. This is important because the memory that these pointers will point to will be dynamically allocated as the corresponding lines are received in the smtp transaction, and when the session ends the memory needs to be free'd. If the smtp session ended abruptly, for example after the helo but before the mail from, then the env_from pointer would not contain a value that can be free'd. To avoid this mis-management of memory, the de-allocation code will explicitly check for the pointers being set to null and not attempt to free them in that case.
Since session_get_add() now returns an array index instead of a pointer to memory, session_delete can now be changed to accept this index and remove the corresponding session from the sessions table directly rather than being supplied with a session ID and having to search the table for it.
int session_delete(struct st_session * session_table, unsigned int * sessions, unsigned int session_to_delete)
{
/*
* Free pointers helo and env_from if they were previously malloc'ed.
*/
if (session_table[session_to_delete].helo != NULL) {
free(session_table[session_to_delete].helo);
}
if (session_table[session_to_delete].env_from != NULL) {
free(session_table[session_to_delete].env_from);
}
if (session_to_delete==((*sessions)-1)) {
(*sessions)--;
return (0);
}
memmove(&session_table[session_to_delete], &session_table[session_to_delete+1], ((*sessions) - session_to_delete - 1) * sizeof(struct st_session));
(*sessions)--;
return (0);
}
As mentioned above, we only free pointers which were previously malloc'ed.If the session being deleted is not the last one in the array, we move all of the following entries back by one.
The actual greylisting code
The time intervals necessary for greylisting and whitelisting the tuples will be set at compile time using three defines. We'll also define a fixed maximum size of the tuple table, and a limit for how many tuple entires can be used by the same IP address:
#define GREY_MIN 600 /* Ten minutes */
#define GREY_MAX 21600 /* Six hours */
#define WHITE_MAX 864000 /* Ten days */
#define MAX_TUPLES 8192
#define MAX_TUPLES_PER_IP 16
The time values are defined in seconds, and will be used in the following ways:
GREY_MINThe minimum amount of time required between a new tuple being seen, and a successive connection from the same tuple resulting in it being whitelisted. In other words, the minimum time a tuple needs to be successfully greylisted in order to be subsequently whitelisted.
GREY_MAXThe maximum amount of time that a tuple can be greylisted and awaiting possible whitelisting, before it is no longer considered to have been seen at all.
WHITE_MAXThe maximum amount of time that a tuple can remain whitelisted since it's last connection without being used, after which time it will be de-listed and treated as an unseen tuple on any future connection.
After an initial connection attempt by an as-yet-unseen tuple, a connection made after GREY_MIN seconds, and before GREY_MAX seconds will result in the tuple being whitelisted.
The core logic for the processing of the tuple data is in a new function tuple_add_update(). This has some similarities with session_get_add() in that we supply it with the tuple table and the current number of entries, (as well as some other parameters), and if it doesn't find an existing entry that matches then it tries to create a new one.
Unlike session_get_add(), though, the new tuple_add_update() function simply updates the tuple table and returns a value to the calling function indicating success or one of two types of failure. It doesn't return an index to the entry or any other data. Actually checking the whitelisted status of a particular IP address will be handled by a separate function that we will see shortly.
The return values from tuple_add_update() are:
0Success. The supplied tuple data either matched an existing entry, or did not match one and was newly added to the table, (possibly replacing an existing and expired entry).
1Failure. The supplied tuple data did not match an existing entry, but the tuple table already contains MAX_TUPLES entries and is therefore full. A new entry could not be added, but the filter can continue without it.
2Failure. Memory could not be allocated for one of the dynamically allocated buffers. This is a critical error and the calling function should terminate the filter program.
Since tuple_add_update() is a fairly long function, we'll look at it in sections.
int tuple_add_update(struct st_tuple * tuple_table, unsigned int * tuples, struct st_session session, unsigned char * env_to, unsigned int env_to_len, int fd_l)
{
unsigned int i;
unsigned int per_ip_count;
time_t now;
now=time(NULL);
for (i=session.ip_len-1; i>0; i--) {
if (session.ip[i]==':') {
session.ip_len=i;
break;
}
}
Here we have the function definition, local variable declaration, and a few lines to remove the trailing port number from the supplied IP address.
The main thing to note about calling tuple_add_update() is that all of the data items for the proposed new tuple are supplied in a struct st_session, except for the envelope to value which is supplied as a separate parameter. This serves two purposes - a slight optimisation, and handling multiple recipients.
Each value ultimately comes from a different line in the filter API dialogue, (and apart from the connecting IP address, a different line in the smtp transaction). Since responses from one session might be interleaved with other sessions, and since a session might end abruptly without sending all of the expected parameters, we first build up the data for any potential new tuple in the session entry. Once it's complete, it can then be passed to tuple_add_update().
Smtpd requires the helo and mail from commands to appear in the smtp dialogue before rcpt to, so we can assume that the envelope to address will be the last data item needed to complete our tuple. We don't need to store the value in the sessions table first, but instead can call tuple_add_update() and pass the value directly from the pipeline_value[] array.
However, multiple rcpt to commands are valid for the same message. In this case we add a separate tuple for each recipient, re-using the previous values of IP address, helo, and envelope from.
Since greylisting is per IP address, repeated attempts, (within the appropriate timescale), to send to any one of the recipients that was specified in the first attempt will result in the sending host being whitelisted. Repeating the smtp transaction with the exact same list of recipients is not a requirement.
The code to strip the port number, (and it's separating colon), simply steps back from the end of the supplied IP and truncates it to the first colon that it finds. If no colon is found then it's left unchanged, although this shouldn't happen given valid input from smtpd.
Note that, as mentioned above in the section about data structures, although we only expect to encounter IP addresses in the source address field, (including both IPv4 and IPv6 addresses), it's also possible that we could be passed a local socket path if a listen on socket directive includes the filter.
Such a local socket path would look something like unix:/var/run/smtpd.sock. The port number truncating code would reduce this to the first four characters, but doing so is perfectly acceptable since only one local socket path is ever used in any particular invocation of smtpd, so we don't need to differentiate between different paths anyway. It's not really worth special-casing the port number truncating code to detect this kind of input and behave differently.
per_ip_count=0;
for (i=0; i<*(tuples); i++) {
if (tuple_table[i].ip_len == session.ip_len && memcmp(tuple_table[i].ip, session.ip, tuple_table[i].ip_len) == 0) {
if (tuple_table[i].flag_ok == 1 && now-tuple_table[i].timestamp <= WHITE_MAX) {
tuple_table[i].timestamp=now;
dprintf (fd_l, "Updated timestamp on already whitelisted tuple %d\n", i);
return (0);
}
if (tuple_table[i].flag_ok == 0 && now-tuple_table[i].timestamp < GREY_MAX) {
per_ip_count++;
}
}
}
if (per_ip_count > (MAX_TUPLES_PER_IP / 2)) {
dprintf (fd_l, "IP already has %d greylisted tuples\n", per_ip_count);
}
for (i=0; i<*(tuples); i++) {
if (tuple_table[i].ip_len == session.ip_len && tuple_table[i].helo_len == session.helo_len && tuple_table[i].env_from_len == session.env_from_len) {
if (tuple_table[i].env_to_len == env_to_len) {
if (memcmp(tuple_table[i].ip, session.ip, tuple_table[i].ip_len) == 0 &&
memcmp(tuple_table[i].helo, session.helo, tuple_table[i].helo_len) == 0 &&
memcmp(tuple_table[i].env_from, session.env_from, tuple_table[i].env_from_len) == 0 &&
memcmp(tuple_table[i].env_to, env_to, tuple_table[i].env_to_len) == 0 ) {
/*
* Matched existing tuple.
* If already whitelisted, then update the timestamp.
* If still greylisted, don't update the timestamp, but whitelist if it's more than GREY_MIN and less then GREY_MAX behind.
*/
if (tuple_table[i].flag_ok == 1) {
if (now-tuple_table[i].timestamp > WHITE_MAX) {
dprintf (fd_l, "Whitelisted tuple %d has exceeded WHITE_MAX, returning to greylisted status "
"with new current timestamp %lld\n", i, now);
tuple_table[i].flag_ok=0;
tuple_table[i].timestamp=now;
return (0);
}
/*
* The following case should already have been handled by the first iteration over the tuples.
* However it's been left here in case future changes inadvertently change the program logic and
* allow this path to be followed.
*/
tuple_table[i].timestamp=now;
dprintf (fd_l, "Updated timestamp on already whitelisted tuple %d, (unexpected code path!)\n", i);
return (0);
}
/*
* Currently greylisted. Update to whitelisted if > GREY_MIN and < GREY_MAX.
* If and only if it's now whitelisted, then also update the timestamp.
*/
if (now-tuple_table[i].timestamp > GREY_MIN && now-tuple_table[i].timestamp < GREY_MAX) {
tuple_table[i].flag_ok=1;
tuple_table[i].timestamp=now;
dprintf (fd_l, "Whitelisted previously greylisted tuple %d\n", i);
return (0);
}
/*
* If greylisted and >= GREY_MAX then update the timestamp as we effectively 'start again'.
*/
if (now-tuple_table[i].timestamp >= GREY_MAX) {
dprintf (fd_l, "Matching tuple %d had passed GREY_MAX. ", i);
if (per_ip_count >= MAX_TUPLES_PER_IP) {
dprintf (fd_l, "IP already has the configured maximum of %d greylisted tuples, ignoring\n", MAX_TUPLES_PER_IP);
return (0);
}
dprintf (fd_l, "Resetting timestamp to current time and keeping as greylisted\n");
tuple_table[i].timestamp=now;
return (0);
}
/*
* Greylisted and hasn't yet reached GREY_MIN. Do nothing.
*/
dprintf (fd_l, "Tuple %d is greylisted for %llu more seconds\n", i, GREY_MIN-(now-tuple_table[i].timestamp));
return (0);
}
}
}
}
Above is the code that implements the core of the actual greylisting logic. This is where we compare timestamps and ultimately flag entries as whitelisted.
The first loop iterates over all of the tuples currently in the tuple table, looking for a valid, (non-expired), already whitelisted entry which matches the IP address - regardless of the other parameters. If a match is found then it's timestamp is updated and the function returns without doing further processing.
This avoids the creation of a large number of excess tuple entries for any IP address that has already been identified as a genuine sender.
At the same time, we keep a count in per_ip_count of the current number of non-expired greylisted tuple entries for this IP address.
The second loop again iterates over all of the current tuples, this time comparing the supplied IP, helo, envelope from, and envelope to values looking for a match. If a match is found, we then progress to checking various combinations of already being whitelisted or not, and how long ago it was last seen.
First we check if it's already flagged as whitelisted. If so, we check if the whitelisting has passed WHITE_MAX and therefore expired, in which case we revert the entry to being greylisted. In either case the timestamp is updated to the current time.
Note that if the whitelisting hadn't expired, we shouldn't reach this part of the code because the first loop should have matched the entry in the tuple table and returned from tuple_add_update() early. However the program logic could easily be changed inadvertently and cause this code path to be followed, so we ensure that an appropriate message is written to the logfile in this case.
If the entry is not already flagged as whitelisted, then we check to see if it's between the minimum and maximum greylisting timeouts. If it is, then we whitelist it and update the timestamp.
At this point, the greylisted entry must have either expired or not yet reached the threshold for being whitelisted. In the case of having expired, we leave it as greylisted and update the timestamp effectively treating it like a previously unseen tuple, as long as this wouldn't cause the total number of active greylisted tuple entries for this IP to exceed MAX_TUPLES_PER_IP. Otherwise we do nothing apart from write an entry to the logfile.
As long the set of values supplied to tuple_add_update() matches an existing entry then one of the above actions will have run, and in each case tuple_add_update() returns zero to the calling function.
Next we have to deal with the case of adding a previously unseen tuple as a new entry.
First, we check that the current IP address hasn't already reached it's allowance of tuple table entries:
if (per_ip_count >= MAX_TUPLES_PER_IP) {
dprintf (fd_l, "IP has already reached the configured maximum of %d greylisted tuples, silently ignoring\n", MAX_TUPLES_PER_IP);
return (0);
}
Assuming that it hasn't then we continue to add the new entry.
Unless the tuple table is full, adding a new entry is trivial as we just write it to the next free slot as determined by the total number of existing tuples:
if (*(tuples) < MAX_TUPLES) {
/*
* If there is space in the table then we just append a new entry.
*/
tuple_table[*tuples].ip_len=session.ip_len;
memcpy(tuple_table[*tuples].ip, session.ip, session.ip_len);
tuple_table[*tuples].helo_len=session.helo_len;
tuple_table[*tuples].helo=malloc_conceal(session.helo_len);
if (tuple_table[*tuples].helo == NULL) {
return (2);
}
memcpy(tuple_table[*tuples].helo, session.helo, session.helo_len);
tuple_table[*tuples].env_from_len=session.env_from_len;
tuple_table[*tuples].env_from=malloc_conceal(session.env_from_len);
if (tuple_table[*tuples].env_from == NULL) {
return (2);
}
memcpy(tuple_table[*tuples].env_from, session.env_from, session.env_from_len);
tuple_table[*tuples].env_to_len=env_to_len;
tuple_table[*tuples].env_to=malloc_conceal(env_to_len);
if (tuple_table[*tuples].env_to == NULL) {
return (2);
}
memcpy(tuple_table[*tuples].env_to, env_to, env_to_len);
tuple_table[*tuples].timestamp=now;
tuple_table[*tuples].flag_ok=0;
dprintf (fd_l, "No matching tuple found in database, adding new tuple at slot %d\n", *tuples);
(*tuples)++;
return (0);
}
Note that if memory allocation for any of the dynamically allocated elements fails, we just immediately return a value of two to the calling function and don't explicitly free any previous allocations that we've just done. This is fine and won't cause a memory leak, because being an unrecoverable error the calling function is expected to terminate the program anyway upon receiving this return value.
Once the tuple table is full, to add a new entry we need to replace an existing one.
Greylisted entries that have passed GREY_MAX, and whitelisted entries that have passed WHITE_MAX are candidates for re-use. The code below simply looks through the tuple table for the first entry that matches either criteria.
for (i=0; i < *(tuples); i++) {
if ( (tuple_table[i].flag_ok == 0 && now-tuple_table[i].timestamp >= GREY_MAX) ||
(tuple_table[i].flag_ok == 1 && now-tuple_table[i].timestamp > WHITE_MAX) ) {
dprintf (fd_l, "Reached MAX_TUPLES when adding new entry - overwriting existing %slisted tuple at slot %d\n",
(tuple_table[i].flag_ok == 0 ? "grey" : "white"), i);
tuple_table[i].ip_len=session.ip_len;
memcpy(tuple_table[i].ip, session.ip, session.ip_len);
tuple_table[i].helo_len=session.helo_len;
free (tuple_table[i].helo);
tuple_table[i].helo=malloc_conceal(session.helo_len);
if (tuple_table[i].helo == NULL) {
return (2);
}
memcpy(tuple_table[i].helo, session.helo, session.helo_len);
tuple_table[i].env_from_len=session.env_from_len;
free (tuple_table[i].env_from);
tuple_table[i].env_from=malloc_conceal(session.env_from_len);
if (tuple_table[i].env_from == NULL) {
return (2);
}
memcpy(tuple_table[i].env_from, session.env_from, session.env_from_len);
tuple_table[i].env_to_len=env_to_len;
free (tuple_table[i].env_to);
tuple_table[i].env_to=malloc_conceal(env_to_len);
if (tuple_table[i].env_to == NULL) {
return (2);
}
memcpy(tuple_table[i].env_to, env_to, env_to_len);
tuple_table[i].timestamp=now;
tuple_table[i].flag_ok=0;
return (0);
}
}
dprintf (fd_l, "Error - MAX_TUPLES is too small\n");
return (1);
}
If no such expired entry is found then tuple_add_update() simply doesn't add an entry for a tuple with the data it was called with.
The filter, (and smtpd), can continue running, with the only effect being that a host which would have been greylisted and started the process of progressing towards being whitelisted, will instead just immediately be forgotten.
Whilst this might not be ideal, if we allowed the number of tuple entries to grow without limit then the filter would eventually face resource exhaustion in some other way.
Although in theory this new-entry-discarding behaviour could be used by a malicious remote host to fill the tuple table, in practice several factors limit the effectiveness of such an attack:
If a remote attacker has the resources to overcome the above issues, they can probably cause a denial of service quite easily without resorting to filling the tuple table.
Displaying the tuple table
To test the new code above we really need to be able to examine the contents of the tuple table whilst it's running.
The following function display_tuple_table() writes the current tuples to the supplied file descriptor.
For the time being we'll just call it with STDOUT_FILENO, but this facility might also be useful during production use so later on we'll use the same function to write to a logfile.
int display_tuple_table(struct st_tuple * tuple_table, unsigned int tuples, int fd_logfile)
{
unsigned int i;
unsigned int flag_expired;
unsigned char * ip;
unsigned char * helo;
unsigned char * env_from;
unsigned char * env_to;
time_t now;
ip=malloc(4096);
helo=malloc(4096);
env_from=malloc(4096);
env_to=malloc(4096);
now=time(NULL);
for (i=0; i < tuples; i++) {
memcpy (ip, tuple_table[i].ip, tuple_table[i].ip_len);
*(ip+tuple_table[i].ip_len)=0;
memcpy (helo, tuple_table[i].helo, tuple_table[i].helo_len);
*(helo+tuple_table[i].helo_len)=0;
memcpy (env_from, tuple_table[i].env_from, tuple_table[i].env_from_len);
*(env_from+tuple_table[i].env_from_len)=0;
memcpy (env_to, tuple_table[i].env_to, tuple_table[i].env_to_len);
*(env_to+tuple_table[i].env_to_len)=0;
if (tuple_table[i].flag_ok == 0) {
flag_expired=(now-tuple_table[i].timestamp >= GREY_MAX ? 1 : 0);
}
if (tuple_table[i].flag_ok == 1) {
flag_expired=(now-tuple_table[i].timestamp > WHITE_MAX ? 1 : 0);
}
dprintf (fd_logfile, "Tuple: %d, IP: %s, helo: %s, from: %s, to: %s, timestamp: %lld, flags: %s %sEXPIRED\n", i, ip, helo, env_from, env_to,
tuple_table[i].timestamp, (tuple_table[i].flag_ok == 1 ? "WHITE" : "GREY"), (flag_expired == 1 ? "" : "UN"));
}
free(env_to);
free(env_from);
free(helo);
free(ip);
return (0);
}
Testing tuple_add_update()
Now that we can display the contents of the tuple table, it becomes quite easy to test the new tuple_add_update() function independently of the rest of the filter code.
The following demo_tuples() routine exercises tuple_add_update(), and allows us to observe the intended greylisting and whitelisting behaviour.
int demo_tuples(struct st_tuple * tuple_table, unsigned int * tuples)
{
unsigned int w;
struct st_session cur_session;
unsigned char * env_to;
unsigned int env_to_len;
memcpy(cur_session.ip, "10.0.0.1", 8);
cur_session.ip_len=8;
cur_session.helo="one.example";
cur_session.helo_len=11;
cur_session.env_from="operator@one.example";
cur_session.env_from_len=20;
env_to="root@example";
env_to_len=12;
tuple_add_update(tuple_table, tuples, cur_session, env_to, env_to_len, STDOUT_FILENO);
memcpy(cur_session.ip, "10.0.0.2", 8);
cur_session.helo="two.example";
cur_session.env_from="operator@two.example";
w=1;
while (1) {
tuple_add_update(tuple_table, tuples, cur_session, env_to, env_to_len, STDOUT_FILENO);
display_tuple_table(tuple_table, *tuples, STDOUT_FILENO);
dprintf (STDOUT_FILENO, "Sleeping for %d second%s\n\n", w, w > 1 ? "s" : "");
sleep(w);
w++;
}
return(0);
}
int main()
{
unsigned int tuples=0;
struct st_tuple * tuple_table;
tuple_table=malloc(MAX_TUPLES * sizeof(struct st_tuple));
demo_tuples(tuple_table, &tuples);
return (0);
}
If you want to compile the above test program, you'll need at least the following includes and definitions that we've already seen in addition to the code for display_tuple_table() and tuple_add_update():
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <time.h>
#include <string.h>
#define GREY_MIN 10
#define GREY_MAX 15
#define WHITE_MAX 12
#define MAX_TUPLES 8192
#define MAX_TUPLES_PER_IP 16
struct st_session {
unsigned char sid[16];
unsigned char ip[48];
unsigned char * helo;
unsigned char * env_from;
unsigned int ip_len;
unsigned int helo_len;
unsigned int env_from_len;
};
struct st_tuple {
unsigned char ip[47];
unsigned char flag_ok;
unsigned char * helo;
unsigned char * env_from;
unsigned char * env_to;
unsigned int ip_len;
unsigned int helo_len;
unsigned int env_from_len;
unsigned int env_to_len;
time_t timestamp;
};
Note that in this example, GREY_MIN, GREY_MAX, and WHITE_MAX have all deliberately been set to much smaller values than would be expected on a production system.
This is purely so that we can see the relevant output from the test program within a reasonable amount of time.
The test code starts by creating two tuples, and then updates just the second one at increasing time intervals.
Here is the output from running it:
No matching tuple found in database, adding new tuple at slot 0
No matching tuple found in database, adding new tuple at slot 1
Tuple: 0, IP: 10.0.0.1, helo: one.example, from: operator@one.example, to: root@example, timestamp: 1694193887, flags: GREY UNEXPIRED
Tuple: 1, IP: 10.0.0.2, helo: two.example, from: operator@two.example, to: root@example, timestamp: 1694193887, flags: GREY UNEXPIRED
Sleeping for 1 seconds
Tuple 1 is greylisted for 9 more seconds
Tuple: 0, IP: 10.0.0.1, helo: one.example, from: operator@one.example, to: root@example, timestamp: 1694193887, flags: GREY UNEXPIRED
Tuple: 1, IP: 10.0.0.2, helo: two.example, from: operator@two.example, to: root@example, timestamp: 1694193887, flags: GREY UNEXPIRED
Sleeping for 2 seconds
Tuple 1 is greylisted for 7 more seconds
Tuple: 0, IP: 10.0.0.1, helo: one.example, from: operator@one.example, to: root@example, timestamp: 1694193887, flags: GREY UNEXPIRED
Tuple: 1, IP: 10.0.0.2, helo: two.example, from: operator@two.example, to: root@example, timestamp: 1694193887, flags: GREY UNEXPIRED
Sleeping for 3 seconds
Tuple 1 is greylisted for 4 more seconds
Tuple: 0, IP: 10.0.0.1, helo: one.example, from: operator@one.example, to: root@example, timestamp: 1694193887, flags: GREY UNEXPIRED
Tuple: 1, IP: 10.0.0.2, helo: two.example, from: operator@two.example, to: root@example, timestamp: 1694193887, flags: GREY UNEXPIRED
Sleeping for 4 seconds
Tuple 1 is greylisted for 0 more seconds
Tuple: 0, IP: 10.0.0.1, helo: one.example, from: operator@one.example, to: root@example, timestamp: 1694193887, flags: GREY UNEXPIRED
Tuple: 1, IP: 10.0.0.2, helo: two.example, from: operator@two.example, to: root@example, timestamp: 1694193887, flags: GREY UNEXPIRED
Sleeping for 5 seconds
Matching tuple 1 had passed GREY_MAX. Resetting timestamp to current time and keeping as greylisted
Tuple: 0, IP: 10.0.0.1, helo: one.example, from: operator@one.example, to: root@example, timestamp: 1694193887, flags: GREY EXPIRED
Tuple: 1, IP: 10.0.0.2, helo: two.example, from: operator@two.example, to: root@example, timestamp: 1694193902, flags: GREY UNEXPIRED
Sleeping for 6 seconds
Tuple 1 is greylisted for 4 more seconds
Tuple: 0, IP: 10.0.0.1, helo: one.example, from: operator@one.example, to: root@example, timestamp: 1694193887, flags: GREY EXPIRED
Tuple: 1, IP: 10.0.0.2, helo: two.example, from: operator@two.example, to: root@example, timestamp: 1694193902, flags: GREY UNEXPIRED
Sleeping for 7 seconds
Whitelisted previously greylisted tuple 1
Tuple: 0, IP: 10.0.0.1, helo: one.example, from: operator@one.example, to: root@example, timestamp: 1694193887, flags: GREY EXPIRED
Tuple: 1, IP: 10.0.0.2, helo: two.example, from: operator@two.example, to: root@example, timestamp: 1694193915, flags: WHITE UNEXPIRED
Sleeping for 8 seconds
Updated timestamp on already whitelisted tuple 1
Tuple: 0, IP: 10.0.0.1, helo: one.example, from: operator@one.example, to: root@example, timestamp: 1694193887, flags: GREY EXPIRED
Tuple: 1, IP: 10.0.0.2, helo: two.example, from: operator@two.example, to: root@example, timestamp: 1694193923, flags: WHITE UNEXPIRED
Sleeping for 9 seconds
Updated timestamp on already whitelisted tuple 1
Tuple: 0, IP: 10.0.0.1, helo: one.example, from: operator@one.example, to: root@example, timestamp: 1694193887, flags: GREY EXPIRED
Tuple: 1, IP: 10.0.0.2, helo: two.example, from: operator@two.example, to: root@example, timestamp: 1694193932, flags: WHITE UNEXPIRED
Sleeping for 10 seconds
Updated timestamp on already whitelisted tuple 1
Tuple: 0, IP: 10.0.0.1, helo: one.example, from: operator@one.example, to: root@example, timestamp: 1694193887, flags: GREY EXPIRED
Tuple: 1, IP: 10.0.0.2, helo: two.example, from: operator@two.example, to: root@example, timestamp: 1694193942, flags: WHITE UNEXPIRED
Sleeping for 11 seconds
Updated timestamp on already whitelisted tuple 1
Tuple: 0, IP: 10.0.0.1, helo: one.example, from: operator@one.example, to: root@example, timestamp: 1694193887, flags: GREY EXPIRED
Tuple: 1, IP: 10.0.0.2, helo: two.example, from: operator@two.example, to: root@example, timestamp: 1694193953, flags: WHITE UNEXPIRED
Sleeping for 12 seconds
Updated timestamp on already whitelisted tuple 1
Tuple: 0, IP: 10.0.0.1, helo: one.example, from: operator@one.example, to: root@example, timestamp: 1694193887, flags: GREY EXPIRED
Tuple: 1, IP: 10.0.0.2, helo: two.example, from: operator@two.example, to: root@example, timestamp: 1694193965, flags: WHITE UNEXPIRED
Sleeping for 13 seconds
Whitelisted tuple 1 has exceeded WHITE_MAX, returning to greylisted status with new current timestamp 1694193978
Tuple: 0, IP: 10.0.0.1, helo: one.example, from: operator@one.example, to: root@example, timestamp: 1694193887, flags: GREY EXPIRED
Tuple: 1, IP: 10.0.0.2, helo: two.example, from: operator@two.example, to: root@example, timestamp: 1694193978, flags: GREY UNEXPIRED
Sleeping for 14 seconds
Whitelisted previously greylisted tuple 1
Tuple: 0, IP: 10.0.0.1, helo: one.example, from: operator@one.example, to: root@example, timestamp: 1694193887, flags: GREY EXPIRED
Tuple: 1, IP: 10.0.0.2, helo: two.example, from: operator@two.example, to: root@example, timestamp: 1694193992, flags: WHITE UNEXPIRED
Sleeping for 15 seconds
Whitelisted tuple 1 has exceeded WHITE_MAX, returning to greylisted status with new current timestamp 1694194007
Tuple: 0, IP: 10.0.0.1, helo: one.example, from: operator@one.example, to: root@example, timestamp: 1694193887, flags: GREY EXPIRED
Tuple: 1, IP: 10.0.0.2, helo: two.example, from: operator@two.example, to: root@example, timestamp: 1694194007, flags: GREY UNEXPIRED
Sleeping for 16 seconds
Matching tuple 1 had passed GREY_MAX. Resetting timestamp to current time and keeping as greylisted
Tuple: 0, IP: 10.0.0.1, helo: one.example, from: operator@one.example, to: root@example, timestamp: 1694193887, flags: GREY EXPIRED
Tuple: 1, IP: 10.0.0.2, helo: two.example, from: operator@two.example, to: root@example, timestamp: 1694194023, flags: GREY UNEXPIRED
Sleeping for 17 seconds
Matching tuple 1 had passed GREY_MAX. Resetting timestamp to current time and keeping as greylisted
Tuple: 0, IP: 10.0.0.1, helo: one.example, from: operator@one.example, to: root@example, timestamp: 1694193887, flags: GREY EXPIRED
Tuple: 1, IP: 10.0.0.2, helo: two.example, from: operator@two.example, to: root@example, timestamp: 1694194040, flags: GREY UNEXPIRED
Sleeping for 18 seconds
We can see that both tuples start off as greylisted and active, (unexpired). Tuple zero quickly expires after 15 seconds, as defined by GREY_MAX.
Tuple one exactly matches GREY_MIN when tuple_add_update() runs for the fifth time, so we are told that it is greylisted for zero more seconds. However, since it needs to actually pass the GREY_MIN threshold and not just equal it, it remains greylisted. After another five seconds it's now passed GREY_MAX, so it's effectively treated as a new tuple, although since it was never actually removed from the table and re-uses the same slot we just see this as the greylisting remaining unexpired.
After another 6 + 7 = 13 seconds, tuple one now qualifies for whitelisting and is duly whitelisted. It remains whitelisted through sleep times of 8, 9, 10, 11, and 12 seconds, as these are less than or equal to WHITE_MAX. Once the sleep time reaches 13 seconds, tuple one is once again returned to greylisted status. It's whitelisted again after 14 seconds, but is then promptly re-greylisted and will no longer meet the criteria for whitelisting since the sleep times progress to 15 seconds and beyond, which exceeds GREY_MAX.
So the basic tuple processing functionality now works.
Validating IPs against entries in the tuple table
To make use of our tuple table to pass or reject incoming mail, we obviously need a way to search it for a given IP address and get the flag showing it's whitelisted status.
This is very simple as we just need to strip the trailing port number in the same way that we did before, and compare the IP address with each entry in the table until we find a match which has flag_ok set to 1.
The following function, ip_check(), does exactly this and just returns zero if such a matching entry is found or one if it isn't:
int ip_check(struct st_tuple * tuple_table, unsigned int tuples, unsigned char * ip, unsigned int ip_len)
{
unsigned int i;
for (i=ip_len-1; i > 0; i--) {
if (ip[i]==':') {
ip_len=i;
break;
}
}
for (i=0; i < tuples; i++) {
if (tuple_table[i].ip_len == ip_len && memcmp(tuple_table[i].ip, ip, ip_len)==0 && tuple_table[i].flag_ok==1) {
tuple_table[i].timestamp=time(NULL);
return (0);
}
}
return (1);
}
Note that we don't need to check the timestamp for having passed WHITE_MAX, because this ip_check() function will only be called when the smtp session reaches the data command. At this point we will have received the required information to build the tuple, and passed it to tuple_add_update() when we received the rcpt to command. If the tuple was whitelisted but the entry had expired, the flag would already have been reset.
This does mean that a client could connect whilst still whitelisted, exceed the maximum whitelisting time during the last part of the smtp transaction, and still have their mail delivered.
In practice being able to exceed WHITE_MAX in this way probably doesn't really matter and might even be desirable. However in this case despite successfully delivering the message, the remote host would have it's whitelisting be removed when they next connected. To avoid this we update the entry's timestamp in the tuple table to be current here in ip_check().
Putting it all together
Now we just need the main() function, which will essentially only need to allocate various buffers, set up the logfile, and parse the various event lines from standard input.
With this in place, we'll have the basic greylisting functionality working and can start to look at ways to enhance it.
First up, we have the variable declarations:
int main()
{
struct st_session * session_table;
struct st_tuple * tuple_table;
unsigned char * raw_buffer;
unsigned char * line_buffer;
unsigned char * host_version;
unsigned int raw_buffer_readpos;
unsigned int raw_buffer_writepos;
unsigned int line_buffer_len;
unsigned int sessions;
unsigned int tuples;
unsigned int result;
unsigned int session_entry;
int fd_logfile;
int fd_database;
struct st_pipeline pipeline;
Next we allocate various buffers and open the logfile:
fd_logfile=open("/tmp/filter_greylog", O_WRONLY | O_CREAT | O_TRUNC, 0600);
raw_buffer_readpos=0;
raw_buffer_writepos=0;
raw_buffer=malloc_conceal(RAW_BUFFER_SIZE);
if (raw_buffer == NULL) {
dprintf (fd_logfile, "Memory allocation error\n");
return (1);
}
line_buffer=malloc_conceal(LINE_BUFFER_SIZE);
if (line_buffer == NULL) {
dprintf (fd_logfile, "Memory allocation error\n");
return (1);
}
line_buffer_len=0;
session_table=malloc_conceal(MAX_SESSIONS * sizeof(struct st_session));
if (session_table == NULL) {
dprintf (fd_logfile, "Memory allocation error\n");
return (1);
}
sessions=0;
tuple_table=malloc_conceal(MAX_TUPLES * sizeof(struct st_tuple));
if (tuple_table == NULL) {
dprintf (fd_logfile, "Memory allocation error\n");
return (1);
}
tuples=0;
Then write a sign-on message with some memory statistics to the logfile:
dprintf (fd_logfile, "Starting with MAX_SESSIONS = %u, (%lu Kb memory allocated), MAX_TUPLES = %u, (%lu Kb)\n", MAX_SESSIONS,
MAX_SESSIONS * sizeof(struct st_session) / 1024, MAX_TUPLES, MAX_TUPLES * sizeof(struct st_tuple) / 1024);
We've already seen the code to perform the initial handshake and note the host smtpd version number, but here it is again in context:
host_version=NULL;
while (line_buffer_len != 12 || memcmp("config|ready", line_buffer, 12) != 0) {
if (line_in(line_buffer, &line_buffer_len, raw_buffer, &raw_buffer_readpos, &raw_buffer_writepos) != 0) {
dprintf (fd_logfile, "I/O error on STDIN whilst waiting for config|ready\n");
return (1);
}
if (line_buffer_len > 21 && memcmp("config|smtpd-version|", line_buffer, 21) == 0) {
host_version=malloc(line_buffer_len-20);
if (host_version == NULL) {
dprintf (fd_logfile, "Memory allocation error\n");
return (1);
}
memcpy(host_version, line_buffer+21, line_buffer_len-21);
*(host_version+line_buffer_len-21)=0;
}
}
write (STDOUT_FILENO,"register|report|smtp-in|link-connect\n",37);
write (STDOUT_FILENO,"register|report|smtp-in|link-disconnect\n",40);
write (STDOUT_FILENO,"register|report|smtp-in|link-identify\n",38);
write (STDOUT_FILENO,"register|report|smtp-in|tx-mail\n",32);
write (STDOUT_FILENO,"register|report|smtp-in|tx-rcpt\n",32);
write (STDOUT_FILENO,"register|filter|smtp-in|data\n",29);
write (STDOUT_FILENO,"register|ready\n",15);
Now we have the main loop, which only exits when line_in() returns an error.
If the input doesn't have at least six fields, then the value pointer for field 5 will be null. We test for this and exit if it is.
The rationale for this is that once the initial handshake is complete, field 5 should always contain the session ID regardless of which event we are processing. If it doesn't then we are receiving data which doesn't comply with the API and since this isn't really a recoverable situation the filter should terminate, (which will in turn bring smtpd down as well).
Sanity check field 5...
while (line_in(line_buffer, &line_buffer_len, raw_buffer, &raw_buffer_readpos, &raw_buffer_writepos)==0) {
dprintf (fd_logfile, "Processing line: %s\n",line_buffer);
parse_pipeline(line_buffer, line_buffer_len, &pipeline);
if (pipeline.value[5]==NULL) {
dprintf (fd_logfile, "Non-conformant data on STDIN\n");
return (1);
}
Now that we know that the input has something in field 5, we can pass that value to session_get_add().
If session_get_add() returns a critical error, or if this is a new session and it doesn't start with a link-connect report, then we yet again terminate the filter.
With the list of events that we registered to receive in the initial handshake, new sessions should always start with a link-connect report. Receiving anything else would suggest a logic error in our code, a bug in smtpd, or other issues such as hardware problems. None of these are really recoverable errors, so bringing the mail system down to avoid further problems is the only sensible course of action.
Otherwise we check if this is a new session, and if so then we do a basic sanity check on the length of the received IP address and store it in the session table if it passes.
Store field 8 as the remote IP...
result=session_get_add(session_table, &sessions, pipeline.value[5], &session_entry);
if (result==2) {
dprintf (fd_logfile, "Maximum number of concurrent sessions reached\n");
return (1);
}
if (result==1) {
if (pipeline.len[0] != 6 || pipeline.len[4] != 12) {
dprintf (fd_logfile, "New session didn't start with link-connect report\n");
return(1);
}
if (memcmp(pipeline.value[0], "report", 6) != 0 || memcmp(pipeline.value[4], "link-connect", 12) != 0) {
dprintf (fd_logfile, "New session didn't start with link-connect report\n");
return(1);
}
if (pipeline.len[8] > 47) {
dprintf (fd_logfile, "Connection source is not a valid IPv4 or IPv6 address\n");
return(1);
}
session_table[session_entry].ip_len=pipeline.len[8];
memcpy (session_table[session_entry].ip, pipeline.value[8], pipeline.len[8]);
}
Curiosity
Pipe characters in filesystem paths
When a connection is made to and from a local socket, the API permits fields 8 and 9 of the link-connect event to contain filesystem paths. Although uncommon, the pipe character is actually a valid character in such a path.
Currently, in the case of a local socket connection, the values of these fields are hard-coded in smtpd at compile time and will be filled with the literal 'unix:' followed by the value of compiler macro SMTPD_SOCKET, (refer to lka_report_smtp_link_connect() in lka_filter.c).
Unless the smtpd source code is deliberately modified and re-compiled, we don't have to concern ourselves with encountering an embedded pipe character here which is part of the supplied path and not a field delimiter.
However, since such a path in field 8 could not be parsed unambiguously, care should be taken when parsing these fields in a filter that might run in an environment with a custom SMTPD_SOCKET and which does further processing using the socket path.
At this point we're ready to start processing the various event lines.
if (pipeline.len[0] == 6 && memcmp(pipeline.value[0], "report", 6)==0) {
After the initial connection, the first event that we should get is link-identify, which corresponds to the smtp helo command. The identity of the remote host is in field 7, and we copy this string to the session table.
Although smtpd will usually reject any second and subsequent helo commands in a single smtp session, it is possible to receive a second helo, (and therefore a second link-identify event), if the connecting host issues starttls and then proceeds to re-identify itself.
In this case, we simply free the memory already allocated to the first helo string and discard the value, replacing it with the newly submitted identity.
Now store the helo identification...
if (pipeline.len[4] == 13 && memcmp(pipeline.value[4], "link-identify", 13)==0) {
/*
* Store the supplied HELO in the session helo pointer
*/
if (session_table[session_entry].helo != NULL) {
dprintf (fd_logfile, "Duplicate helo for the same session\n");
free (session_table[session_entry].helo);
}
session_table[session_entry].helo = malloc_conceal(pipeline.totlen[7]);
if (session_table[session_entry].helo == NULL) {
dprintf (fd_logfile, "Memory allocation error\n");
return (1);
}
memcpy (session_table[session_entry].helo, pipeline.value[7], pipeline.totlen[7]);
session_table[session_entry].helo_len = pipeline.totlen[7];
}
It's a similar process for the tx-mail event. Several of these can occur in the same session if multiple emails are being sent together.
if (pipeline.len[4] == 7 && memcmp(pipeline.value[4], "tx-mail", 7)==0) {
/*
* Store the supplied envelope FROM in the session env_from pointer
*/
if (session_table[session_entry].env_from != NULL) {
dprintf (fd_logfile, "Duplicate envelope FROM for the same session\n");
free (session_table[session_entry].env_from);
}
session_table[session_entry].env_from = malloc_conceal(pipeline.totlen[8]);
if (session_table[session_entry].env_from == NULL) {
dprintf (fd_logfile, "Memory allocation error\n");
return (1);
}
memcpy (session_table[session_entry].env_from, pipeline.value[8], pipeline.totlen[8]);
session_table[session_entry].env_from_len = pipeline.totlen[8];
}
Of course, if the IP of the connecting host is already whitelisted then we don't actually need to build tuples for each delivery attempt. But doing so gives us more flexibility if we want to change the whitelisting criteria in the future.
The rcpt to command sends us a tx-rcpt event, and at this point we have all of the values we need to add or update a tuple in the tuple table.
We call tuple_add_update, with the IP, helo identity, and envelope sender pulled from the session table, and the recipient passed directly from the pipeline.
For debugging and informational purposes we also dump a copy of the current tuple table to the logfile.
Once we have the envelope recipient address, the complete tuple can be passed to tuple_add_update(). We also log the current tuple table for good measure.
if (pipeline.len[4] == 7 && memcmp(pipeline.value[4], "tx-rcpt", 7)==0) {
/*
* Add or update an entry in the tuple table.
* Values for IP, helo, and envelope from come from the session table.
* The recipient address is supplied from the current pipeline.
*/
result=tuple_add_update(tuple_table, &tuples, session_table[session_entry], pipeline.value[8], pipeline.totlen[8], fd_logfile);
if (result==2) {
dprintf (fd_logfile, "Memory allocation error in tuple_add_update\n");
return (1);
}
display_tuple_table(tuple_table, tuples, fd_logfile);
}
For link-disconnect we just need to remove the current session entry.
if (pipeline.len[4] == 15 && memcmp(pipeline.value[4], "link-disconnect", 15)==0) {
/*
* Remove the current session from the sessions database.
*/
dprintf (fd_logfile, "Removing session from sessions table\n");
session_delete(session_table, &sessions, session_entry);
}
}
And that's all for the report events!
We only process one filter request, and that's the data event.
Don't confuse this with the data-line event that we parsed in the first demonstration filter. The data event doesn't actually pass any of the email content to us, but instead just waits for an indication as to whether to proceed to accepting the message or to immediately reject it.
The heavy lifting has already been done by the tuple table processing code, and our ip_check() function just returns a simple flag indicating whether the IP address is whitelisted or not.
Remember that, at least as far as this filter is concerned, once an IP address has been whitelisted it doesn't matter who the mail is addressed to or from, or even what the identity in the helo was. Whitelisting is on a purely per IP address basis.
The data event is where we actually pass or reject the incoming message. The code here is trivial as the calculations have all been done elsewhere.
if (pipeline.len[0] == 6 && memcmp(pipeline.value[0], "filter", 6)==0) {
if (pipeline.len[4] == 4 && memcmp(pipeline.value[4], "data", 4)==0) {
result=ip_check(tuple_table, tuples, session_table[session_entry].ip, session_table[session_entry].ip_len);
write (STDOUT_FILENO, "filter-result|", 14);
write (STDOUT_FILENO, pipeline.value[5], 34);
if (result==0) {
write (STDOUT_FILENO, "proceed\n", 8);
dprintf (fd_logfile, "Passing\n");
}
if (result==1) {
write (STDOUT_FILENO, "reject|421 Service temporarily unavailable\n", 43);
dprintf (fd_logfile, "Rejecting with 421\n");
}
}
}
}
Of course, now that we have closed the outer while loop if we reach here then line_in() didn't return any data.
This will either be an error or simply normal termination of smtpd.
In either case, we just exit with a value of 1.
return (1);
}
(Almost) ready for use!
Although the greylisting filter is now functional and could in some cases be used on a production system in it's current form as a substitute for spamd, there is still scope for improvement.
Writing the tuple database to disk
Although the greylisting filter is now functional and could in some cases be used on a production system in it's current form as a substitute for spamd, there is still scope for improvement.
Currently, every time the filter is shut down the tuple database is lost as it is stored exclusively in RAM. Although previously whitelisted hosts will quickly be re-whitelisted one by one as they re-connect, this might become tedious for a large site that happens to re-start smtpd repeatedly in a short period of time, (which might happen due to configuration changes or other maintenance).
One solution to this problem is to write the tuple database to disk when the filter exits, and check for the presence of such a previously written file on filter startup.
Of course, if the machine crashes or the filter isn't shut down cleanly then the database will still be lost, (although if this happens shortly after a clean restart then the previous on-disk copy would still be fairly up to date). This seems like an acceptable trade-off in return for avoiding having to write the details of each tuple to disk as it is created or updated, similar to the way that spamd manages it's database.
Trapping SIGPIPE
Whilst testing the filter at the shell, hitting control-D will send an end-of-file to the filter's standard input and cause the main while loop to exit. In this case, the program terminates cleanly and we could just add code to write the database to disk immediately before the final return.
The situation is slightly different when the filter is invoked from smtpd. Since the filter's standard input is one end of a pipe, when smtpd closes it's end of the same pipe our filter receives a signal, SIGPIPE. By default, a program receiving SIGPIPE terminates immediately.
Although this detail hasn't affected normal use or even shutdown of the filter during testing, it does mean that the naive approach of just adding code outside of the big while loop won't work because that code will never be run.
One possibility is simply to ignore SIGPIPE by setting it's default action to SIG_IGN:
#include <signal.h>
[...]
signal (SIGPIPE, SIG_IGN);
This will certainly work, and result in similar program flow to what we have when testing from the shell. So we could then add whatever code we wanted at the end of the program and it would run when smtpd terminated the filter.
The limitation with this approach is that the program completely loses the knowledge that a signal occurred.
This is useful information here, because we can use it to differentiate between an EOF being sent deliberately from the shell and EOF being signalled due to the other end of the pipe being closed - we don't necessarily want to write the tuple database to disk unless the program was actually invoked from smtpd.
Additionally, although smtpd currently shouldn't send an EOF to the filter except when closing the pipe, if for some reason a future change or bug or caused it to do so we might want to take different action in the filter.
So instead of simply ignoring SIGPIPE, we'll trap it and write a custom signal handler to run when it's received. This is nothing more than a void function that sets a flag and then immediately returns.
The flag variable is declared in global scope. Since the signal handler is a void function that can be called at any moment, there are not many alternatives for passing values to and from it.
volatile sig_atomic_t flag_sigpipe;
The actual signal handler function is trivial:
void sigpipe()
{
flag_sigpipe=1;
return ;
}
Then we just install the signal handler at or near the beginning of main(). In practice, there isn't much point in installing it before the initial memory allocations are done, so we'll put it just before the sign-on message.
signal(SIGPIPE, &sigpipe);
Fun fact!
Atomic variables in signal handlers
Readers who are not familiar with writing signal handlers might be wondering about the use of the sig_atomic_t type for the flag.
This special type exists because signal handlers can, (with a few exceptions), be called at any moment. This includes when the main program is in the middle of manipulating a value that the signal handler will also touch. If all operations performed on the value are truly atomic, then we can be sure that conflicting reads and updates won't happen.
On OpenBSD this type is defined per-architecture in /sys/arch/*/include/signal.h, and always as a regular integer. In fact, sig_atomic_t is also an int on just about every other system that smtpd is currently likely to run on.
So in practice we could almost certainly use the int type for the flag and expect the compiler to produce exactly the same binary output.
Nevertheless, portable C code should use sig_atomic_t for values that are manipulated by signal handlers.
However...
In contrast, the use of the volatile keyword is quite important and is much more likely to affect the generated code.
By way of example, shortly we'll add code to the end of main() to check the value of the flag_sigpipe flag.
Observing the assembler output from clang version 13.0.0 on OpenBSD 7.3, generating code for the amd64 architecture, when the variable is not declared as volatile the comparison of the literal, (immediate value), 0x01 is done directly against the value in memory:
cmpl $1, flag_sigpipe(%rip)
Not volatile
movl flag_sigpipe(%rip), %eax
cmpl $1, %eax
Volatile
When declared as a volatile, the value is first moved from memory to a register, (%eax), and the comparison made is an immediate value against the register.
The upshot of all this is that flag values in signal handlers should usually be declared as volatile sig_atomic_t, but in practice and from a C coding point of view, they can be thought of as normal integers.
For the benefit of non-asm coders trying to understand this, note that the reference to %rip in the code above is quite normal and expected for a PIE binary and that flag_sigpipe is indeed a memory reference.
Testing the signal handler
Adding the following code to the end of main() before the final return will cause a message to be written to the logfile when the filter exits after having received SIGPIPE:
if (flag_sigpipe==1) {
dprintf (fd_logfile, "Got SIGPIPE\n");
}
Note that with our signal handler in place, simply sending SIGPIPE to the running process doesn't automatically cause it to terminate anymore. It will now continue waiting for the read call in line_in() to return an EOF or an error condition.
Of course, when the filter is invoked by smtpd and then terminated, the read call will indeed promptly return zero indicating EOF.
However, if we invoke the filter interactively from the shell and then separately send it a SIGPIPE, it will continue to parse any input we give it until we explicitly send an EOF to it's standard input, (or alternatively terminate the program in some other way).
Data output at filter shutdown
The actual writing of the tuple database to disk will be handled by a new function, write_tuples_to_disk().
We'll look at this before the reading code, as it more clearly illustrates the on-disk data format.
First, we add a call to this new function to the flag testing code we just saw:
if (flag_sigpipe==1) {
dprintf (fd_logfile, "Got SIGPIPE\n");
result=write_tuples_to_disk(tuple_table, tuples, fd_database, fd_logfile);
}
Here we are passing an already-open file descriptor for the database to the new function.
The database file will be opened at program start to read any existing entries. Since we don't need to read and write data to disk during normal filter operation, we could close it immediately after the initial read and only re-open it again for writing when the program exits.
However, whilst this would be a valid way of handling the database file, it has the disadvantage that we would need to do filesystem operations right at the end of the program's operation. This in turn makes it impossible to drop the wpath, or cpath promises, and undesirable to drop flock.
Instead, by doing it just once at startup and keeping the file open we can lose wpath, cpath, and flock, and therefore reduce the pledge call to just the stdio promise, (since writing to an already open file descriptor, as well as calling close(), doesn't require anything more than stdio).
The new function itself essentially just writes out the tuple data items one-by-one:
int write_tuples_to_disk(struct st_tuple * tuple_table, unsigned int tuples)
{
unsigned int i;
if (lseek (fd_database, 0, SEEK_SET) == -1) {
dprintf (fd_logfile, "Seek failed on %s\n", TUPLE_DATABASE);
return (1);
}
if (ftruncate (fd_database, 0) == -1) {
dprintf (fd_logfile, "Truncate failed on %s\n", TUPLE_DATABASE);
return (1);
}
write (fd_database, "*TUPLE_DATABASE*", 16);
dprintf (fd_logfile, "Writing %d tuples to %s\n", tuples, TUPLE_DATABASE);
for (i=0; i<tuples; i++) {
write(fd_database, &(tuple_table[i].ip), 47);
write(fd_database, &(tuple_table[i].flag_ok), 1);
write(fd_database, &(tuple_table[i].ip_len), sizeof(unsigned int));
write(fd_database, &(tuple_table[i].helo_len), sizeof(unsigned int));
write(fd_database, &(tuple_table[i].env_from_len), sizeof(unsigned int));
write(fd_database, &(tuple_table[i].env_to_len), sizeof(unsigned int));
write(fd_database, &(tuple_table[i].timestamp), sizeof(time_t));
write(fd_database, tuple_table[i].helo, tuple_table[i].helo_len);
write(fd_database, tuple_table[i].env_from, tuple_table[i].env_from_len);
write(fd_database, tuple_table[i].env_to, tuple_table[i].env_to_len);
}
return (0);
}
The code starts by seeking to the beginning of the file and truncating it to zero length. Assuming that both of these operations succeed, (which they should do, unless there is an I/O error, since we already hold the file open with a lock), we write a 16-byte file magic followed by the actual data.
The integer values are written in the system's native byte order for speed and simplicity. This does mean that a tuple database written on a little-endian machine won't restore correctly on a big-endian architecture, for example. However, since the on-disk copy of the tuples is only intended to allow it to be carried over through a restart of smtpd, this also shouldn't be an issue in practice.
Loading the tuple database from disk
At filter startup we obviously need to read back any previously written tuple database.
First, we'll add code at beginning of main() to declare a new file descriptor and open the database. If the file doesn't already exist, it's created and locked ready to be written to later on.
Note that the call to open() will block if an exclusive lock for TUPLE_DATABASE can't immediately be obtained. This shouldn't be an issue in normal use if other programs are not accessing the database.
int fd_database;
fd_database=open(TUPLE_DATABASE, O_RDWR | O_CREAT | O_EXLOCK, 0600);
Next, we'll call a new function, read_tuples_from_disk(), just after writing the sign-on message to the logfile.
result=read_tuples_from_disk(tuple_table, &tuples, fd_logfile);
if (result == 0) {
dprintf (fd_logfile, "Loaded data for %d existing tuples from %s\n", tuples, TUPLE_DATABASE);
display_tuple_table(tuple_table, tuples, fd_logfile);
}
The function itself is essentially the opposite of the write code, although it's somewhat longer due to more error handling and also because we have to actually allocate the memory for each data item that has a separately recorded length.
We'll look at it in sections. First the function definition itself and some variable declarations, nothing special here.
int read_tuples_from_disk(struct st_tuple * tuple_table, unsigned int * tuples, int fd_logfile)
{
int totlen;
unsigned int flag_expired;
unsigned char * magic;
struct stat sb;
time_t now;
Next we try to open the on-disk tuple database, and check that it has the correct file magic.
if (fd_database == -1) {
dprintf (fd_logfile, "Open of %s failed\n", TUPLE_DATABASE);
return (1);
}
if (fstat(fd_database, &sb) == -1) {
dprintf (fd_logfile, "Stat of %s failed\n", TUPLE_DATABASE);
return (1);
}
if (sb.st_size == 0) {
dprintf (fd_logfile, "On-disk database %s is empty\n", TUPLE_DATABASE);
return (0);
}
if (sb.st_size < 16) {
dprintf (fd_logfile, "Size of %s is less than 16 bytes\n", TUPLE_DATABASE);
return (1);
}
magic=malloc(16);
if (read (fd_database, magic, 16) != 16) {
dprintf (fd_logfile, "Failed to read file magic\n");
free(magic);
return (1);
}
if (memcmp("*TUPLE_DATABASE*", magic, 16) != 0) {
dprintf (fd_logfile, "On-disk tuple database has bad file magic\n");
free(magic);
return (1);
}
free(magic);
dprintf (fd_logfile, "On-disk tuple database has valid file magic\n");
Since we'll be reading variable sized records and it's possible that the data file could have been truncated or contain invalid length information, we'll keep track of the remaining data in the file as we go. This starts off as the file length minus the size of the file magic that we've already read.
totlen=sb.st_size-16;
Tuples that have already expired when we read them can be discarded. To avoid calling time() repeatedly, we store the current timestamp in now.
now=time(NULL);
Now we arrive at the main loop that will read through the data file and fill in the records in the in-memory tuple table.
Following the file magic, the data for each individual tuple can be visualised as being stored on disk in two blocks, first the fixed-size items, then the variable sized items. Since we need the length values from the first block in order to correctly read the second block, the main loop in the reading routine will be divided in to two sections.
In the first section, we begin by checking that we haven't already reached the maximum size allocated for the in-memory database. This might happen if the filter was re-compiled with a lower value for MAX_TUPLES compared to what it was when the database was written. In this case, we return from read_tuples_from_disk() with a zero status to indicate success and keep the entries that have already been processed.
Next, we check that there is enough data remaining in the file to read the IP address, flag, four length values and timestamp. If not, we return to the calling function keeping any tuples that had already been successfully read in previous iterations of the loop.
At this point we read the various items in to memory one by one. These reads should succeed since we already checked that there is sufficient data remaining in the file, but they could still possibly fail due to an I/O error. To handle this case gracefully, we jump forward to an error handler at the end of the function. We'll see this error handler shortly, but in essence it just writes a message to the log and returns to the calling function with any tuples that had already been read.
while (totlen != 0) {
if (*tuples == MAX_TUPLES) {
dprintf (fd_logfile, "On-disk tuple database exceeds current MAX_TUPLES of %d\n", MAX_TUPLES);
return (0);
}
if (totlen < (47 + 1 + 4*sizeof(unsigned int) + sizeof(time_t))) {
dprintf (fd_logfile, "Unexpected end of data within first block\n");
return (0);
}
if (read(fd_database, &(tuple_table[*tuples].ip), 47) == -1) {
goto error_handler;
}
if (read(fd_database, &(tuple_table[*tuples].flag_ok), 1) == -1) {
goto error_handler;
}
if (read(fd_database, &(tuple_table[*tuples].ip_len), sizeof(unsigned int)) == -1) {
goto error_handler;
}
if (read(fd_database, &(tuple_table[*tuples].helo_len), sizeof(unsigned int)) == -1) {
goto error_handler;
}
if (read(fd_database, &(tuple_table[*tuples].env_from_len), sizeof(unsigned int)) == -1) {
goto error_handler;
}
if (read(fd_database, &(tuple_table[*tuples].env_to_len), sizeof(unsigned int)) == -1) {
goto error_handler;
}
if (read(fd_database, &(tuple_table[*tuples].timestamp), sizeof(time_t)) == -1) {
goto error_handler;
}
If we reach here, then all of the above reads succeeded.
Before doing anything with the length arguments that we've just read, we'll ensure that they are within the expected range.
Of course, if the file is unmodified from when it was written at the previous filter shutdown then the arguments should indeed be within the expected range. Since we can't guarantee that, it's entirely possibly that we might have read completely nonsensical values for any of the lengths. Additionally, if we are reading a valid file from a machine with a different endianness then the following test will catch that too.
if (tuple_table[*tuples].ip_len > 47 || tuple_table[*tuples].helo_len > 255 || tuple_table[*tuples].env_from_len > 2048 ||
tuple_table[*tuples].env_to_len > 2048) {
dprintf (fd_logfile, "Invalid length argument in tuple database\n");
return (0);
}
With all of the length values checked, we can now reduce totlen by the amount of data we just read.
totlen -= (47 + 1 + 4*sizeof(unsigned int) + sizeof(time_t));
The second section of the read loop starts by checking the remaining data in the file in the same way that we did above:
if (totlen < tuple_table[*tuples].helo_len + tuple_table[*tuples].env_from_len + tuple_table[*tuples].env_to_len) {
dprintf (fd_logfile, "Unexpected end of data within second block\n");
return (0);
}
Once we know that there is sufficient data remaining to read, we allocate memory for the data items based on the length values already received:
tuple_table[*tuples].helo=malloc(tuple_table[*tuples].helo_len);
tuple_table[*tuples].env_from=malloc(tuple_table[*tuples].env_from_len);
tuple_table[*tuples].env_to=malloc(tuple_table[*tuples].env_to_len);
Again, we read each item and jump to the error handler if the call to read() fails.
Since we've already allocated memory for the current tuple's helo, envelope from address, and envelope to address, in the case of a read error we jump to a slightly earlier part of the error handler which will free those allocations.
if (read(fd_database, tuple_table[*tuples].helo, tuple_table[*tuples].helo_len) == -1) {
goto error_handler_with_free;
}
if (read(fd_database, tuple_table[*tuples].env_from, tuple_table[*tuples].env_from_len) == -1) {
goto error_handler_with_free;
}
if (read(fd_database, tuple_table[*tuples].env_to, tuple_table[*tuples].env_to_len) == -1) {
goto error_handler_with_free;
}
totlen -= (tuple_table[*tuples].helo_len + tuple_table[*tuples].env_from_len + tuple_table[*tuples].env_to_len);
flag_expired=0;
if (tuple_table[*tuples].flag_ok == 0 && (now-tuple_table[*tuples].timestamp >= GREY_MAX)) {
flag_expired=1;
}
if (tuple_table[*tuples].flag_ok == 1 && (now-tuple_table[*tuples].timestamp > WHITE_MAX)) {
flag_expired=1;
}
if (flag_expired==0) {
(*tuples)++;
}
if (flag_expired==1) {
free(tuple_table[*tuples].helo);
free(tuple_table[*tuples].env_from);
free(tuple_table[*tuples].env_to);
}
}
close (fd_database);
return (0);
That's the end of the main code path for read_tuples_from_disk(), but we still have the error handling code.
A jump to error_handler_with_free will fall through to the regular error handler after performing the free()'s.
error_handler_with_free:
free(tuple_table[*tuples].helo);
free(tuple_table[*tuples].env_from);
free(tuple_table[*tuples].env_to);
error_handler:
dprintf (fd_logfile, "Read call returned error\n");
return (0);
}
And that's all we need to preserve the tuple database across restarts of smtpd!
IP address pools and truncating IPv6 addresses
As mentioned in the introduction, one of the fundamental problems with the greylisting of IP addresses is that some of the larger email providers use pools of outgoing smtp servers.
With this setup, each delivery attempt typically comes from a different IP address, with a different helo. Any greylisting filter that doesn't do further processing will naturally treat all of these connections as completely unrelated. A separate tuple will be created for each one, but unless one of the delivery attempts randomly happens to come from an IP that has already been seen and greylisted, (and before that greylisting expires), then none of the IPs will ever be whitelisted and the delivery will never succeed. This can be seen when using spamd itself, and also with our greylisting filter in it's current form.
Making any assumptions about what IP addresses might be assigned to such a server pool operating over IPv4 is difficult without consulting external information such as the appropriate whois database.
However, with IPv6 the situation is somewhat better. Multiple connections from the same /64 block have a high probability of originating from mail servers operating in a pool, so we might be able to improve the reliability of mail delivery at little cost in complexity if we simply truncate all incoming IPv6 addresses to their corresponding /64.
This approach certainly isn't perfect and has it's limitations, but since the overall problem stems from the very concept of filtering connections based solely on IP address, a comprehensive solution would almost certainly involve introducing new and additional criteria for the greylisting and eventual whitelisting process.
That might indeed be an interesting topic to explore in the future, but since the focus here today is on learning about the smtpd filters API by re-implementing the core functionality of spamd, we'll stick to the simple approach.
Of course, if a server pool is small enough, then the probability of getting a repeat connection from the same IP and thus it becoming whitelisted is high enough that mail delivery will mostly work, but this certainly can't be relied on. Even though the size of typical IPv4 address allocations places something of a practical limit on the size of such a server pool operating over IPv4, this alone isn't enough to completely mitigate the problem.
Implementing this idea is actually very easy, since the IP field in both the sessions table and the tuples table actually allows us to store any arbitrary string of characters up to 47 bytes.
Since the value of this field is only used internally by the filter code as a token and not actually passed as an IP address to any networking code, all we need to do is determine whether the stored string is indeed a valid IPv6 address or not, and if it is then overwrite it with a new token in any format that is convenient for us to handle and uniquely identifies the first 64 bits of the address. If the supplied string is not a valid IPv6 address, then we just leave it unchanged.
We don't need to preserve the port number, or even check that it's present and valid. However, we do need to make sure that our new token contains a trailing colon separator after all other significant characters, because the ip_check() function strips it's supplied ip argument back to the last colon.
Of course, we could always code a special case in ip_check() to detect our custom token and not apply the trailing colon truncating in that case, but simply adding one extra byte in the new ipv6_truncate() function is much quicker and easier.
Actually parsing the string to validate it as an IPv6 address and extract the first 64 bits is complicated slightly by the fact that we have to recognise the double colon token which substitutes for one or more blocks of 0000. Whilst this most frequently appears in the lower 64 bits, (the interface identifier), there is no reason why it can't appear in the high 64 bits that we're interested in.
Our truncated IPv6 addresses will be produced by the following print format string:
%04x:%04x:%04x:%04x::0/64:
So a typical output might be:
2001:0db8:0001:0002::0/64:
We purposely won't use the double colon to represent an arbitrary number of zero blocks, as outputting the long form above ensures that the token is always exactly 26 characters.
The code for ipv6_truncate()
#define VALID_HEX(i) ((( i >= '0' && i <= '9') || (i >= 'a' && i <= 'f') || (i >= 'A' && i <= 'F')) ? 1 : 0)
#define VALUE_HEX(i) ((( i >= '0' && i <= '9') ? i - '0' : 0) + ((i >= 'a' && i <= 'f') ? i - 'a' + 10 : 0) + ((i >= 'A' && i <= 'F') ? i - 'A' + 10 : 0))
These macros let us manipulate hex values encoded as ASCII characters.
VALID_HEX returns 1 if the supplied character is a valid hex digit, or 0 otherwise.
VALUE_HEX converts the hex digit in to a decimal value 0 - 15.
int ipv6_truncate(unsigned char * addr, unsigned int * len)
{
unsigned int i;
unsigned int colons;
unsigned int flag_double_colon;
unsigned int dcword;
unsigned int cword;
unsigned int words[8];
unsigned int hex_digits;
unsigned int specified;
colons=0;
flag_double_colon=0;
dcword=0;
cword=0;
hex_digits=0;
specified=0;
The value of each block of four hex digits parsed will be stored in the words[] array, initially ignoring the double colon token.
If do we encounter the double colon, we note it's position and then shift the words that follow it to the end of the array once we know how many zero words it's substituting for.
We count the current word in cword, the number of colon separators seen in colons, and the number of hexdigits seen so far in the current word in hex_digits.
In specified, we store the number of words that we've actually read hex digits for. This might differ from cword if the supplied address actually starts with the double colon.
When we see the double colon, we set flag_double_colon, and store it's location, (the current value of cword), in dcword.
if (*len < 4 || *addr != '[') {
return (1);
}
The shortest valid address would be [::], so return immediately if we have a shorter string than that.
if (*(addr+1)==':' && *(addr+2)!=':') {
return (1);
}
If the address starts with a colon at all, it must be a double colon to be valid.
memset(words, 0, sizeof(words));
for (i=1; i<*len; i++) {
Zero the words[] array, and loop through the input string starting after the initial opening square bracket.
if (cword==8) {
return (1);
}
More than eight words would be invalid, so we return the string unchanged.
if (*(addr+i)==']') {
if (hex_digits>0) {
specified++;
}
if (*(addr+i-1)==':' && (i < 3 || *(addr+i-2)!=':')) {
return (1);
}
break ;
}
A closing square bracket indicates the end of the string that we're interested in.
If we saw any hex digits for the last word, (in other words, we didn't end on the double colon), then we increase the specified counter.
If we did end with a colon, it must have been a double colon to be valid. If not, we return to the calling function without doing any further parsing. Otherwise we exit the byte-by-byte loop and continue further below.
if (!(VALID_HEX(*(addr+i))) && *(addr+i)!=':') {
return (1);
}
If we see anything other than a hex digit or a colon, the string isn't a valid IPv6 address.
if (VALID_HEX(*(addr+i))) {
if (hex_digits==4) {
return (1);
}
hex_digits++;
words[cword] = words[cword] << 4;
words[cword] |= VALUE_HEX(*(addr+i));
}
More than four hex digits would also be invalid. Otherwise add the current digit to the value that is being built up in words[cword].
if (*(addr+i)==':') {
if (*(addr+i-1)==':') {
if (flag_double_colon==1) {
return (1);
}
flag_double_colon=1;
dcword=cword;
cword--;
}
cword++;
if (hex_digits>0) {
specified++;
}
hex_digits=0;
}
}
Handle both single and double colons.
Only one double colon is permitted, so return the string unmodified if we see a second double colon.
If this is the first double colon then decrease cword by one as the following code that handles the regular single colon separator will increase it un-necessarily.
Otherwise we're just processing a regular separator, so we increase cword and also increase specified if we actually saw any digits.
Then loop back to process the next character.
if (i==*len) {
return (1);
}
if (flag_double_colon==0 && cword != 7) {
return (1);
}
if (flag_double_colon==1 && specified==8) {
return (1);
}
Outside the loop, some quick sanity checks.
If we didn't see a closing square bracket, i will have increased past the last character. We check for this and return in that case.
If we didn't see a double colon, we should have exactly eight words. Likewise, if we did see the double colon, but also got eight fields specified, then the address is invalid.
Otherwise it is a valid IPv6 address.
#define SHIFT (7-cword)
#define TOSHIFT (cword-dcword+1)
if (flag_double_colon==1 && SHIFT != 0 && TOSHIFT != 0) {
for (i=7; i >= dcword; i--) {
words[i]=(i > 7 - TOSHIFT ? words[i-SHIFT] :0);
}
}
sprintf (addr, "%04x:%04x:%04x:%04x::0/64:", words[0], words[1], words[2], words[3]);
*len=26;
return (0);
}
If we saw a double colon, then we shift the words following it the correct number of places forward and store zero in any words that it substituted for.
Then it's just a case of formatting the new truncated address and writing it in to the supplied buffer.
With the new function in place, it's just a matter of adding code to main() to call it after we copy the connecting IP address from the pipeline to the sessions table:
[...]
memcpy (session_table[session_entry].ip, pipeline.value[8], pipeline.len[8]);
ipv6_truncate(session_table[session_entry].ip, &session_table[session_entry].ip_len);
}
[...]
Now any valid IPv6 address will be truncated to the first 64 bits, and entered in to the sessions table in the format described above.
As a result of this, the tuple_add_update() and ip_check() functions will treat IPv6 connections from the same /64 block as if they were connections from the same IP address.
Summary and conclusions
That's it!
We've created a greylisting filter that speaks to the smtpd filter API, and done it all in about 1000 lines and 32K of C source code!
Downloads and cheat sheet
Download station!
If you want to try the greylisting filter out for yourself, you can download the source code shown above all in one file and ready to compile.
The source code for the first basic demonstration filter we looked at first is also included.
Cheat sheet!
The steps required to compile and install the greylisting filter are as follows:
  1. Download the tar archive containing the source code and extract it somewhere.
  2. Create a new user, 'greylist', to run the filter.
  3. Place the extracted source code file greylist.c in /home/greylist/greylist.c.
  4. Compile the source code, creating a binary in /home/greylist/greylist:
    $ cd /home/greylist
    $ cc -O3 -o greylist_binary greylist.c
  5. Define the filter in /etc/mail/smtpd.conf:
    filter greylist_filter proc-exec /home/greylist/greylist_binary user "greylist" group "greylist"
  6. Add the filter to one or more listen directives in /etc/mail/smtpd.conf, for example:
    listen on ::1 filter greylist_filter
    listen on 192.0.2.1 filter greylist_filter
    listen on if0 filter greylist_filter
  7. Restart smtpd:
    # /etc/rc.d/smtpd restart
Notes: