Linux

Xtables-addons: GeoIP, TARPIT and the psd Module I Ported

By Nawawi JamiliPublished 9 min read

Stock iptables covers most of what a server firewall needs. But at some point you want something it does not ship. You may want to drop SSH from every country except your own. Or keep a scanner stuck on an unused port for twenty minutes, or notice a port scan as it happens. Xtables-addons is where those extensions live. It collects extra match and target modules that were never accepted into the mainline kernel or iptables, maintained by Jan Engelhardt with many other contributors.

I have a small personal connection to it: the psd port scan detection match in Xtables-addons is code I ported. Everything else in the package is other people's work.

What it is and why it exists

Before Xtables-addons there was patch-o-matic (later patch-o-matic-ng), which added extensions by patching the kernel and iptables sources and recompiling both. The upstream project page lists what was wrong with that: slow kernel rebuilds, modules broken on 64-bit or big-endian machines, and missing error handling that could crash the kernel.

Xtables-addons replaced that with two independent halves. The kernel part is a set of out-of-tree modules (xt_geoip.ko, xt_TARPIT.ko and so on) built against your kernel headers, which is why distributions ship them via DKMS. The userspace part is a set of libxt_*.so plugins that iptables loads to parse and print the new options. Nothing needs patching. The current release is 3.31 (18 August 2026). It needs iptables 1.6.0 and kernel 5.4 or newer.

I read the code for this article in a GitHub mirror of the repository.

What is in the package

The mconfig file lists every module. All are built by default. The highlights:

  • geoip and asn: match source or destination by country code, or by autonomous system number.
  • TARPIT: accept a TCP connection, advertise a zero window and keep the client stuck.
  • pknock: port knocking, either a TCP port sequence or an HMAC-signed UDP packet.
  • quota2: named byte or packet counters, readable and resettable through /proc.
  • psd and lscan: port scan detection.
  • condition, iface, ipp2p, DELUDE, CHAOS, SYSRQ, ACCOUNT and more.

Installing it

On Debian and Ubuntu the source package builds xtables-addons-common (the userspace plugins and helper scripts) and xtables-addons-dkms (the kernel modules, rebuilt by DKMS for each kernel). According to sources.debian.org, bookworm has 3.23, trixie 3.27 and sid 3.31.

bash
apt install xtables-addons-common xtables-addons-dkms

On the RHEL family I found no package in the EPEL 8/9 or ELRepo el8/el9 listings when I checked. So plan to build from source with the usual autotools steps (run autoreconf -fi first in a Git checkout). You need the running kernel's headers and the iptables development headers:

bash
./configure
make
make install    # as root

The INSTALL file warns that heavily patched vendor kernels such as RHEL's often break the build, because their API no longer matches the version number. ./configure --without-kbuild builds only the userspace plugins.

How I tested this

The host runs AlmaLinux 8 with an ELRepo 5.16.10 kernel. I built just the three kernel modules I needed (geoip, TARPIT and psd, selected in mconfig) from the 3.31 source with Kbuild against the running kernel:

bash
make -C /lib/modules/$(uname -r)/build M=$PWD/extensions \
    XA_ABSTOPSRCDIR=$PWD modules

A short-lived privileged container loaded them with insmod, and they were removed with rmmod afterwards. Everything else ran in temporary Debian bookworm containers with iptables v1.8.9 (nf_tables) and the userspace plugins compiled against the container's libxtables. Two internal Docker networks with no route out used real ranges: 175.136.250.0/24, which the October 2026 DB-IP lite data puts in Malaysia, and 175.146.250.0/24, which it puts in China. A "firewall" container on both ran an HTTP server on port 80, and the rules lived only in its network namespace:

output
-A INPUT -p tcp -m tcp --dport 2222 -j TARPIT --tarpit
-A INPUT -m psd --psd-weight-threshold 21 --psd-delay-threshold 300 \
   --psd-lo-ports-weight 3 --psd-hi-ports-weight 1 -j DROP
-A INPUT -p tcp -m tcp --dport 80 -m geoip --source-country MY -j ACCEPT
-A INPUT -p tcp -m tcp --dport 80 -j DROP

That is the container's iptables -S output, with the psd line wrapped. The raw table also held -A PREROUTING -p tcp -m tcp --dport 2222 -j CT --notrack. pknock and quota2 were not tested.

Country blocking with geoip

The geoip match needs a database first. xt_geoip_dl fetches the free DB-IP country lite CSV for the current month into the current directory, and xt_geoip_build packs it into one .iv4 and one .iv6 file per country. On Debian they live in /usr/libexec/xtables-addons. The build script needs the Perl modules Net::CIDR::Lite and Text::CSV_XS (a comment says plain Text::CSV works too, which is what I used):

terminal
$ ./xt_geoip_dl
$ wc -l dbip-country-lite.csv
710834 dbip-country-lite.csv
$ mkdir xt_geoip
$ ./xt_geoip_build -D xt_geoip
710834 entries total
  102 IPv4 ranges for AD
  122 IPv6 ranges for AD
...
 1857 IPv4 ranges for MY
  864 IPv6 ranges for MY
...
$ ls xt_geoip | wc -l
502
$ ./xt_geoip_query -D xt_geoip -4 MY | head -3
1.9.0.0-1.9.255.255
1.32.0.0-1.32.127.255
1.178.102.0-1.178.102.255

The build took about a minute. For real use, xt_geoip_build -s writes straight to /usr/share/xt_geoip, the path the plugin has hard-coded. In the test image I copied just the MY and CN files there, then sent one HTTP request to port 80 from each client with curl --max-time 4:

output
curl from 175.136.250.10 (MY) -> 200
curl from 175.146.250.10 (CN) -> 000   (curl exit 28)
num  pkts bytes target  prot  ...
3       7   449 ACCEPT  6     ... tcp dpt:80 -m geoip --source-country MY
4       3   180 DROP    6     ... tcp dpt:80

The Malaysian client got its page. The Chinese one timed out on the DROP rule. On a real server, "SSH only from Malaysia" looks like this:

bash
iptables -A INPUT -p tcp --dport 22 -m geoip ! --src-cc MY -j DROP

Two details from the source: one rule takes at most 31 country codes (XT_GEOIP_MAX). And the plugin reads the database when the rule is added and copies the ranges into the kernel. So a rebuilt database changes nothing until you reload the rules. A monthly systemd timer that rebuilds and then restores your saved ruleset works well for this.

TARPIT for a decoy SSH port

TARPIT completes the handshake, advertises a zero window and ignores the client, keeping no per-connection state. The man page says the remote side gives up after 12 to 24 minutes. --honeypot (normal window, to capture what the client sends) and --reset are the other modes.

The module registers for TCP only, in the INPUT and FORWARD hooks, so -p tcp is required. The man page's main caution is conntrack: without an exemption every tarpitted connection gets a conntrack entry that is not needed, so untrack the port in the raw table first. With SSH on 22 and nothing on 2222:

bash
iptables -t raw -A PREROUTING -p tcp --dport 2222 -j CT --notrack
iptables -A INPUT -p tcp --dport 2222 -j TARPIT

From the Malaysian client, nc reported success, received nothing, and ended only when timeout killed it. ss on the client showed another connection, three seconds old, as established:

terminal
$ timeout 6 nc -v 175.136.250.2 2222 </dev/null
Connection to 175.136.250.2 2222 port [tcp/*] succeeded!
exit status 124 after 6.0 s
$ ss -tni dst 175.136.250.2    # detail line trimmed
State Recv-Q Send-Q  Local Address:Port   Peer Address:PortProcess
ESTAB 0      0      175.136.250.10:56206 175.136.250.2:2222

Never point TARPIT at a port your own monitoring or load balancer connects to, or they will hang too.

psd: the port scan detector I ported

The psd match is older than Xtables-addons. It was written at astaro AG in 2000 and 2001 by Markus Hennig and Dennis Koslowski, derived from Solar Designer's scanlogd. Jan Rekorajski converted it from a target into a match, Harald Welte fixed a storage bug, and Martijn Lievaart ported it to Linux 2.6 in 2004.

My part was keeping it working as the kernel changed. I ported psd to the 2.6.18 and 2.6.24 kernels in 2007 and 2008 and updated the userspace library for iptables 1.4. In August 2009 I ported it into Xtables-addons. The first commit for extensions/xt_psd.c is "psd: import 20090807 code base". Jan Engelhardt reworked it within the week and has maintained it since, with other contributors.

psd keeps a small table of recent source hosts. Each new destination port adds weight: --psd-lo-ports-weight (default 3) for ports below 1024 and --psd-hi-ports-weight (default 1) for the rest, so passive FTP does not look like a scan. A host's entry is forgotten if more than --psd-delay-threshold passes without a new port, in hundredths of a second (default 300, so 3 seconds). Once the total reaches --psd-weight-threshold (default 21), the packet matches. TCP packets with ACK or RST are ignored, so replies to your own outgoing connections do not count.

The test rule (second above) used those defaults with -j DROP. I ran a TCP connect scan of ports 1 to 1000 from the Malaysian client:

terminal
$ nmap -sT -Pn -n -T4 --max-retries 0 -p 1-1000 175.136.250.2
Not shown: 994 filtered tcp ports (no-response)
PORT    STATE  SERVICE
53/tcp  closed domain
113/tcp closed ident
443/tcp closed https
445/tcp closed microsoft-ds
554/tcp closed rtsp
587/tcp closed submission

Nmap done: 1 IP address (1 host up) scanned in 2.43 seconds

The psd DROP counter went from 0 to 994 packets. xt_psd.c explains the six "closed" ports. The first SYN from a new host creates its entry with weight 3 and is let through. Each later new port (all were below 1024) adds 3 and is checked with >= against the threshold. So the sixth port leaves the weight at 18 and still passes, and the seventh reaches 21 and matches. From then on every new port from that host matches. The six that got through hit ports with nothing listening, hence "closed". nmap probes ports in random order, so which six varies.

A curl to port 80 from the same client immediately after the scan returned 000: port 80 was just one more new port from a known scanner. Four seconds after that attempt gave up, longer than the three-second delay threshold, the entry had expired and the same request returned 200. psd stops a scan in progress. It is not a ban list.

Port knocking and quotas

I did not test these two. The examples come from the man pages. pknock hides a port until the client knocks. In the simple TCP example, SYNs to 4002, 4001 and 4004 in that order, at most 10 seconds apart, open port 22 to that address for 60 minutes:

bash
iptables -A INPUT -p tcp -m pknock --knockports 4002,4001,4004 --strict \
    --name SSH --time 10 --autoclose 60 --dport 22 -j ACCEPT

Knock state appears in /proc/net/xt_pknock/SSH. Anyone watching the traffic can replay a TCP knock. The UDP mode with --opensecret is the replay-resistant one.

quota2 gives named counters that count down to a limit or, with --grow, just count: a cheap traffic meter readable under /proc.

Xtables-addons and nftables

Xtables-addons only provides iptables extensions. The source contains no nftables code. They work with iptables-nft, as the test showed, through nf_tables' compatibility layer for xtables extensions, so the kernel modules are still required. Native nft rules cannot call geoip or TARPIT. Under pure nftables, country blocking means loading ranges into an interval set yourself. The upstream geoip page notes that set-based approaches use more memory than geoip's packed format.