pgipfauth: Postgres-backed IPF Userland Authorization Daemon
The IPF firewall software has a little-used feature that allows one to write rules that will attempt to build a packet disposition by passing the packet (headers only or headers and payload) to a program running outside the kernel. The userland program must open the /dev/ipauth device and perform ioctl calls to wait for a packet and to pass back the disposition. An example program exists in the official IPF source distributions; this program holds lists of authorized IPs in memory. The goal of pgipfauth is to use a Postgres database to hold a persistent, possibly large list of authorized IP addresses and consult that list as needed. Authorizations are cached by pgipfauth to attempt to decrease the number of database queries which must be performed.

Combined with the connection state table of IPF, the typical TCP connection profile is handled quite efficiently:
  1. Initial TCP packet triggers a call to pgipfauth
  2. pgipfauth queries the database with the orginating IP, decides to BLOCK or PASS, caches the IP + disposition
  3. IPF adds the TCP session to its state table
  4. Subsequent packets in TCP session are passed or blocked based solely on the IPF state table
For the lifetime of the cache record added in step 2, the primary difference in the connection profile is that the database is never queried: pgipfauth merely returns the cached disposition.
The Command Line
The pgipfauth daemon accepts the following command line options:
usage: /usr/local/pgipfauth/current/bin/pgipfauth {options} options: --help/-h this info --quiet/-q don't print anything except critical information --annoying/-a print so much that the sysadmin will go crazy trying to read our log files --daemon/-d run as a daemon (not in the foreground) --invalidator/-i [path] use [path] as the FIFO we should watch for cache invalidation requests; default is /usr/local/pgipfauth/0.1/etc/cache-invalidate --config/-c [path] use [path] as the configuration file; default configuration file is at: /usr/local/pgipfauth/0.1/etc/pgipfauth.conf signals: HUP force the daemon to dump its cache, close the database connection, and re-read the configuration file USR1 write current info for the cache and database to the daemon's stdout USR2 force the daemon to purge its cache TERM,ABRT,QUIT,INT terminate the daemon gracefully
The --daemon option just means that the process forks off a child and exits (the usual daemon behavior). Cache coherency issues and their relation to the USR2 signal and --invalidator CLI option are covered later in this document.
The Configuration File
An XML configuration file is used to provide the majority of the startup parameters to the pgipfauth daemon:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE pgipfauth-conf PUBLIC "-//UDEL//DTD pgipfauth configuration 1.0//EN" "http://metal1.nss.udel.edu/DTDs/pgipfauth-conf_1.0.dtd"> <pgipfauth-conf version="1.0" authoritative="yes" ipf-logging="yes" ipf-keep-state="yes" ipf-return-reset="yes" > <database> <dbname>ipfauth</dbname> <schema>alternate</schema> <host-group>FlexLM</host-group> </database> <cache enabled="yes" size="256" ttl="600" honor-ip-port="no"> <search method="stateful"/> <adaptive enabled="yes" grow-by="64" critical-fraction="0.10"/> </cache> </pgipfauth-conf>
The version attribute MUST be included in the pgipfauth-conf tag; it can also have the following attributes: In the configuration above, pgipfauth is instructed to return the following dispositions back to IPF: The database element provides the connection information pgipfauth needs in order to connect to the database for authorization queries. The following sub-elements are used when connecting to the database: There are two additional sub-elements that configure the nature of the database queries: The nature of the authorization SQL and host-groups will be covered in the next chapter.

By default, no caching is done by pgipfauth. The cache is configured by the cache element; this element has two attributes: The honor-ip-port option is available to conserve cache lines in the instance where the inbound port is just not important. An example is the application for which pgipfauth was created: an license daemon that listens on a random TCP/IP port needs a large port range to be "open" but access still needs to be controlled to keep unauthorized users from grabbing licenses. In this case, the connection profile dictates that the TCP/IP port is not integral in authorizing a connection.
NOTE: The inbound IP port is always passed from pgipfauth to the SQL authorization functions (see next section). You must write your SQL authorization functions in such a way that they treat the port the same way you configure the cache to treat the port!
A search sub-element specifies which algorithm should be used when searching the cache for an IP. The algorithm is selected by providing the method attribute, which may have the values: Finally, the adaptive sub-element is used to enable/disable pgipfauth's ability to automatically add more cache lines if the cache is full and the miss ratio reaches some critical value: The configuration file is stored by default in an etc directory inside the install directory of pgipfauth. An alternate configuration can be passed to the daemon by use of the --config command-line option:
% pgipfauth --config /etc/pgipfauth.conf
Authorization SQL and Host-Groups
IP authorization uses the following SQL statement:
SELECT pgipfAuthorize($1,$2)
A sample SQL table and pgipfAuthorize function are worth a thousand words of prose description:
CREATE TABLE validIPAddresses ( hostAddress INET UNIQUE NOT NULL ); CREATE LANGUAGE plpgsql; CREATE FUNCTION pgipfAuthorize(INET,INTEGER) RETURNS BOOLEAN AS $$ DECLARE aRow RECORD; remoteIP ALIAS FOR $1; serverPort ALIAS FOR $2; BEGIN SELECT * INTO aRow FROM validIPAddresses WHERE hostAddress = remoteIP; IF FOUND THEN RETURN TRUE; END IF; RETURN FALSE; END; $$ LANGUAGE plpqsql;
NOTE: The SQL function is given the IP address not as a string but as a Postgres INET type. The table used for the authorized IPs should store them as this type, as well, or the function must type-cast $1 accordingly. Using INET allows the address to be passed to Postgres far more quickly since a call to inet_ntoa is avoided. The inbound port (second argument) is passed as a Postgres INTEGER type.
Host groups should be setup be creating a table named hostGroup. The table must at least include the following fields:
CREATE TABLE hostGroup ( hgId SERIAL PRIMARY KEY, name TEXT UNIQUE NOT NULL );
The hdId must be an integral field, and the auto-incrementing SERIAL works nicely. The name field must be a textual type: a CHARACTER VARYING(32) UNIQUE NOT NULL would work equally well. To use host groups, a pgipfAuthorizeWithHostGroup function must be created and the validIPAddress table might be redeclared as
CREATE TABLE validIPAddresses ( hostGroup INTEGER REFERENCES hostGroup(hgId) ON DELETE CASCADE, hostAddress INET UNIQUE NOT NULL ); CREATE LANGUAGE plpgsql; CREATE FUNCTION pgipfAuthorizeWithHostGroup(INTEGER,INET,INTEGER) RETURNS BOOLEAN AS $$ DECLARE aRow RECORD; hgId ALIAS FOR $1; remoteIP ALIAS FOR $2; serverPort ALIAS FOR $3; BEGIN SELECT * INTO aRow FROM validIPAddresses WHERE hostAddress = remoteIP AND hostGroup = hgId; IF FOUND THEN RETURN TRUE; END IF; RETURN FALSE; END; $$ LANGUAGE plpqsql;
NOTE: The first argument to pgipfAuthorizeWithHostGroup is the host group integral identifier, passed as an SQL INTEGER type.
The reason an SQL function is used for authorization is quite simply the fact that it affords the greatest amount of flexibility in how the actual lookup should be accomplished. For example, if we wanted to generate some database usage statistics for pgipfauth we could make the following modifications within the database:
CREATE TABLE lookupLog ( hostAddress INET NOT NULL, hostGroup INTEGER REFERENCES hostGroup(hgId) ON DELETE CASCADE, allow BOOLEAN, lookupWhen TIMESTAMP WITH TIME ZONE DEFAULT now() ); CREATE FUNCTION pgipfAuthorizeWithHostGroup(INTEGER,INET,INTEGER) RETURNS BOOLEAN AS $$ DECLARE aRow RECORD; hgId ALIAS FOR $1; remoteIP ALIAS FOR $2; serverPort ALIAS FOR $3; BEGIN SELECT * INTO aRow FROM validIPAddresses WHERE hostAddress = remoteIP AND hostGroup = hgId; IF FOUND THEN INSERT INTO lookupLog (hostAddress,hostGroup,allow) VALUES (remoteIP,hgId,TRUE); RETURN TRUE; END IF; INSERT INTO lookupLog (hostAddress,hostGroup,allow) VALUES (remoteIP,hgId,FALSE); RETURN FALSE; END; $$ LANGUAGE plpgsql;
Each time the database is queried for an IP authorization, the IP, host group, and result of the query is added to the lookupLog table with a timestamp for the request. This data can then be mined for database lookups per minute, etc.
Cache Coherency
It is quite possible that the following situtation could arise:
  1. A user attempts to connect to the IPF host and is blocked since his/her IP is not in the authorization table
  2. User adds his/her IP address to the authorization table
  3. User attempts to connect again, and is still blocked; user must wait at most the cache TTL for his/her traffic to pass
There are two solutions to this problem: one, drop the cache TTL to a relatively short time (say 30 seconds). Of course, this decreases the usefulness of the cache since an increased number of database lookups will be experienced relative to a longer TTL. The second option is to provide for on-the-fly eviction of IPs from the cache. There are two cache eviction methods available in pgipfauth: Purging the cache in its entirety is done by passing the USR2 signal to the running instance of pgipfauth. Unless you used the --quiet command line option, you'll see this signal be processed in the program's stdout:
Fri Feb 27 14:04:41 2009 [6857] : [PACKET] 128.175.2.26:24562 => 128.175.13.107:80 PASS (00020512) [CACHE HIT] Fri Feb 27 14:04:56 2009 [6857] : [PACKET] 128.175.13.49:24562 => 128.175.13.107:80 BLOCK (00001111) Fri Feb 27 14:04:59 2009 [6857] : [PACKET] 128.175.13.49:24563 => 128.175.13.107:80 BLOCK (00001111) [CACHE HIT] Fri Feb 27 14:05:00 2009 [6857] : [PACKET] 128.175.13.49:24564 => 128.175.13.107:80 BLOCK (00001111) [CACHE HIT] Fri Feb 27 14:05:32 2009 [6857] : [NOTICE] the internal authorization cache has been purged Fri Feb 27 14:05:36 2009 [6857] : [PACKET] 128.175.13.49:24565 => 128.175.13.107:80 BLOCK (00001111) Fri Feb 27 14:05:38 2009 [6857] : [PACKET] 128.175.2.26:24563 => 128.175.13.107:80 PASS (00020512)
To evict specific IP addresses, the addresses must be written (in textual form, separated by whitespace and/or newlines) to a FIFO which pgipfauth opens. By default, this FIFO is available in the etc directory inside the install directory of pgipfauth and is named cache-invalidate. The FIFO must at least be readable by the user under which pgipfauth is running:
prw-rw---- 1 root staff 0 Feb 27 13:51 cache-invalidate
If I wished to invalidate the two IP addresses observed in the stdout output above:
% echo "128.175.2.26 128.175.13.49" >> cache-invalidate
Watching the stdout for pgipfauth:
Fri Feb 27 14:23:19 2009 [6857] : [NOTICE] invalidating 128.175.2.26 in cache Fri Feb 27 14:23:19 2009 [6857] : [NOTICE] invalidating 128.175.13.49 in cache Fri Feb 27 14:23:27 2009 [6857] : [PACKET] 128.175.2.26:24564 => 128.175.13.107:80 PASS (00020512) Fri Feb 27 14:23:29 2009 [6857] : [PACKET] 128.175.2.26:24565 => 128.175.13.107:80 PASS (00020512) [CACHE HIT]
In short, the program written to add or remove IP addresses from the authorization table (through a web interface, etc) should also utilize the selective eviction FIFO to keep the cache in-sync with those database changes. For example, a Postgres trigger function could be written such that any changes to the table are accompanied by the function's writting to the invalidation FIFO:
#include "postgres.h" #include "executor/spi.h" /* this is what you need to work with SPI */ #include "commands/trigger.h" /* ... and triggers */ #ifdef PG_MODULE_MAGIC PG_MODULE_MAGIC; #endif extern Datum ipfcacheinvalidate(PG_FUNCTION_ARGS); PG_FUNCTION_INFO_V1(ipfcacheinvalidate); Datum ipfcacheinvalidate(PG_FUNCTION_ARGS) { TriggerData *triggerData = (TriggerData *) fcinfo->context; TupleDesc tupleDesc; HeapTuple resultTuple; int fnum; /* make sure it's called as a trigger */ if (!CALLED_AS_TRIGGER(fcinfo)) elog(ERROR, "ipfcacheinvalidate: not called by trigger manager"); tupleDesc = triggerData->tg_relation->rd_att; resultTuple = triggerData->tg_trigtuple; /* Find the appropriate field: */ fnum = SPI_fnumber(tupleDesc, "hostaddress"); if ( fnum >= 0 ) { char* ipAddress = SPI_getvalue(resultTuple, tupleDesc, fnum); if ( ipAddress && strlen(ipAddress) ) { FILE* cacheFIFO = fopen("/usr/local/pgipfauth/current/etc/cache-invalidate", "w"); if ( cacheFIFO ) { /* On UPDATE, let's do BOTH ipAddresses: */ if ( TRIGGER_FIRED_BY_UPDATE(triggerData->tg_event) ) { char* newIPAddress = SPI_getvalue(triggerData->tg_newtuple, tupleDesc, fnum); fprintf(cacheFIFO, "%s %s\n", ipAddress, (newIPAddress && strlen(newIPAddress) ? newIPAddress : "") ); } else { fprintf(cacheFIFO, "%s\n", ipAddress); } fclose(cacheFIFO); } else { elog(INFO, "ipfcacheinvalidate: unable to write to cache FIFO"); } } } /* All done: */ return PointerGetDatum(resultTuple); }
This chunk of code is compiled and linked as a shared object which can be dynamically loaded into Postgres. Within Postgres:
CREATE FUNCTION ipfcacheinvalidate() RETURNS TRIGGER AS '/usr/local/pgipfauth/0.1/src/ipfcacheinvalidate.so' LANGUAGE C; CREATE TRIGGER ipfcacheinvalidate AFTER INSERT OR UPDATE OR DELETE ON validIPAddress FOR EACH ROW EXECUTE PROCEDURE ipfcacheinvalidate();
Now, when a row is added or deleted or modified in the validIPAddress table, Postgres will automagically write the IP address (or IP addresses, in the case of an UPDATE) to the cache invalidation FIFO! Does it work, though?
ipfauth=# delete from validIPAddress; DELETE 3 ipfauth=# insert into validIPAddress (hostAddress) values ('128.175.2.26'); INSERT 0 1 ipfauth=# insert into validIPAddress (hostAddress) values ('128.175.2.25'); INSERT 0 1 ipfauth=# select * from validIPAddress; hostaddress -------------- 128.175.2.26 128.175.2.25 (2 rows) ipfauth=# update validIPAddress set hostAddress = '128.175.13.16' where hostAddress = '128.175.2.25'; UPDATE 1
with the following pgipfauth output:
Fri Feb 27 15:12:35 2009 [6857] : [NOTICE] invalidating 76.116.128.241 in cache Fri Feb 27 15:12:35 2009 [6857] : [NOTICE] invalidating 128.175.2.26 in cache Fri Feb 27 15:12:35 2009 [6857] : [NOTICE] invalidating 128.175.13.49 in cache : Fri Feb 27 15:12:42 2009 [6857] : [NOTICE] invalidating 128.175.2.26 in cache Fri Feb 27 15:16:22 2009 [6857] : [NOTICE] invalidating 128.175.2.25 in cache : Fri Feb 27 15:23:42 2009 [6857] : [NOTICE] invalidating 128.175.2.25 in cache Fri Feb 27 15:23:42 2009 [6857] : [NOTICE] invalidating 128.175.13.16 in cache