Geoip filtering can be somewhat controversial. Rather than delve into any supposed benefits or effectiveness of the practice, this post is going to describe how to accomplish geoip filtering via nftables. The concept is simple. Create a set for the blocked IP ranges and simply drop traffic to or from the IPs in the set.
Getting the IP Database
Any geoip filtering is only as good as the data that matches IP addresses to their location. There are a few free options available. Most of these options use a freemium model where the free version has less granularity and fewer features than the paid version of their services.
The service I use is IP2Location Lite which provides Class C level of accuracy. The main site is http://www.ip2location.com. Information for the lite version can be found at http://lite.ip2location.com. The data is in a CSV file. The entries do not contain the actual IP address. Rather it is an encoded integer that reflects the IP. For example:
"16777216","16777471","US","United States of America"
There are some significant advantages to storing the data this way, particularly for efficient searching, but for the purposes of creating nftables sets, the IP addresses will need to be decoded. This process is best done via a script shown later.
nftables
It’s helpful to understand some of the inner workings of nftables so that rules can be created efficiently. In nftables, a combination of hook and priority are used to determine packet flow. The image below shows the flow of packets based on the hook used.

The second significant element is the priority assigned to the chain. From the nftables wiki:
“Each nftables base chain and flowtable is assigned a priority that defines its ordering among other base chains and flowtables and Netfilter internal operations at the same hook. For example, a chain on the prerouting hook with priority -300 will be placed before connection tracking operations.”
Because we already know that any traffic from or to the set of IP addresses will be dropped, those rules should be in place as early as possible. I use the “prerouting” hook and a priority of “raw” (-300) for the chain. This ensures that the packets are dropped before conntrack is ever involved. Although not technically necessary, I create a separate table for geo filtering to help keep the rules and sets organized and compartmentalized. The below commands will create the table, chain, set, and rules to block traffic to and from Antarctica.
# nft add table geofilter
# nft add chain ip geofilter PREROUTING { type filter hook prerouting priority raw \; policy accept \; }
# nft add set ip geofilter GEOIP-AQ { type ipv4_addr \; flags interval \; auto-merge \; }
# nft add element ip geofilter GEOIP-AQ { 103.178.35.0 - 103.178.35.255 }
# nft add rule ip geofilter PREROUTING ip saddr @GEOIP-AQ log prefix "DROP_GEOIP-AQ_" counter drop comment "DROP_GEOIP-AQ"
# nft add rule ip geofilter PREROUTING ip daddr @GEOIP-AQ log prefix "DROP_GEOIP-AQ_" counter drop comment "DROP_GEOIP-AQ"
The result of the commands looks like
# nft list table geofilter
table ip geofilter { set GEOIP-AQ { type ipv4_addr flags interval auto-merge elements = { 103.178.35.0/24 } }
chain PREROUTING { type filter hook prerouting priority raw; policy accept; ip saddr @GEOIP-AQ log prefix "DROP_GEOIP-AQ" counter packets 0 bytes 0 drop comment "DROP_GEOIP-AQ" ip daddr @GEOIP-AQ log prefix "DROP_GEOIP-AQ" counter packets 0 bytes 0 drop comment "DROP_GEOIP-AQ" } }
All other chains and rules will still be traversed. Someone from not Antarctica can still attempt to brute force SSH access, but if an input rule blocks SSH, they will be denied at that point.
If instead of blocking Antarctica, I wanted to allow only Antarctica, I would change the policy for PREROUTING to drop and change the rules from drop to accept.
Automating the process
Creating a small set for filtering can be accomplished manually, but if I wanted to filter the former Soviet states, it would be a long and tedious task to find and translate all of the entries from the IP database into IP addresses and then to add them to a set or multiple sets. If a new country needed to be added, the process would have to be repeated for those IPs as well.
The script below was created to automatically build the table, sets, and rules for geoip blocking. To change the list of countries, simply modify as appropriate the line
blockCountries=( "RU" "UA" "BY" "LT" "LV" "EE" "MD" "UZ" "KZ" "KG" "TJ" "TM" "GE" "AZ" "AM" )
Create a cron job to run the script regularly and it will download a new copy of the database and add new entries. Don’t forget to save the rules so that they survive reboot.
#!/bin/bash
#Lets shift to some mathematics, Ip2Location use precise algorithm to calculate ip number from actual IP address. #Which is calculated by this formula : 16777216*w + 65536*x + 256*y + z #where w,x,y,z are the 1st part,2nd ,3rd and 4th part of IPv4 address respectively. # #e.g if your ip address is 182.68.132.49 then w=182, x=68, y=132, z=49 #ipNumber is =( 16777216 * 182 ) + ( 65536 * 68 ) + ( 256 * 132 ) + ( 49 ) = 3057943601
############################################################################### # GLOBAL VARIABLES ############################################################################### dbFile="IP2LOCATION-LITE-DB1.CSV.ZIP" dbDownloadURL="https://download.ip2location.com/lite" dataFile="IP2LOCATION-LITE-DB1.CSV" nftCommand="/usr/sbin/nft" nftTable="geofilter" nftFamily="ip" nftChain="PREROUTING" blockCountries=( "RU" "UA" "BY" "LT" "LV" "EE" "MD" "UZ" "KZ" "KG" "TJ" "TM" "GE" "AZ" "AM" )
############################################################################### # END GLOBAL VARIABLES ###############################################################################
############################################################################### # FUNCTIONS ###############################################################################
############################################################################### # # decodeIP # # Decodes the IP as given in the database file to return A.B.C.D # Requires one parameter # # formula to decode ip address: # # fourthOctet = value % 256 # thirdOctet = ((value - fourthOctet) / 256) % 256 # secondOctet = (((value - fourthOctet) - (256 * thirdOctet)) / 65536) % 256 # firstOctet = ((((value - fourthOctet) - (256 * thirdOctet) - (65536 * secondOctet)) / 16777216) % 256 # ############################################################################### function decodeIP () { if [ $# -ne 1 ]; then echo "ERROR: decodeIP requires a parameter. Check calls to the function" exit 1 fi local encodedIPValue="$1" fourthOctet=$(( encodedIPValue % 256 )) thirdOctet=$(( $(( (encodedIPValue - ${fourthOctet}) / 256 )) % 256 )) secondOctet=$(( $(( (encodedIPValue - ${fourthOctet} - $(( ${thirdOctet} * 256 ))) / 65536 )) % 256 )) firstOctet=$(( $(( (encodedIPValue - ${fourthOctet} - $(( ${thirdOctet} * 256 )) - $(( ${secondOctet} * 65536 ))) / 16777216 )) % 256 ))
echo "${firstOctet}.${secondOctet}.${thirdOctet}.${fourthOctet}" }
############################################################################### # END FUNCTIONS ###############################################################################
# Download and extract an updated data file if [ -f ${dbFile} ]; then rm -f ${dbFile} fi
# Download latest db file /usr/bin/curl -o ${dbFile} ${dbDownloadURL}/${dbFile} /usr/bin/unzip -f -o ${dbFile} # Make sure that the table and chain exist if [[ $(${nftCommand} list tables | grep ${nftTable}) ]]; then : # do nothing else ${nftCommand} add table ${nftFamily} ${nftTable} fi if [[ $(${nftCommand} list chain ${nftFamily} ${nftTable} ${nftChain}) ]]; then : # do nothing else ${nftCommand} add chain ${nftFamily} ${nftTable} ${nftChain} { type filter hook prerouting priority raw \; policy accept \; } fi
# Build all sets and rules for ((i=0;i<${#blockCountries[@]};i++)); do # create the set if it does not exist # set will be named GEOIP-${countryCode} echo "Creating set GEOIP-${blockCountries[${i}]}" if [[ $(${nftCommand} list sets | grep GEOIP-${blockCountries[${i}]}) ]]; then : # do nothing else ${nftCommand} add set ${nftFamily} ${nftTable} GEOIP-${blockCountries[${i}]} { type ipv4_addr \; flags interval \; auto-merge \; } fi
# Populate the set with IP ranges from the database file # Use grep to filter db to just the country currently in scope echo "Populating set GEOIP-${blockCountries[${i}]}" grep ${blockCountries[${i}]} ${dataFile} | awk -F\" '{print $2" "$4" "$6}' | while IFS=" " read -r fromIPCode toIPCode countryCode do fromIP=$(decodeIP ${fromIPCode}) toIP=$(decodeIP ${toIPCode}) ${nftCommand} add element ${nftFamily} ${nftTable} GEOIP-${countryCode} { ${fromIP}-${toIP} } done echo "Population of GEOIP-${blockCountries[${i}]} complete" echo "Updating nftables rules for GEOIP-${blockCountries[${i}]}" # Each blocked country will have 2 rules to deny. # One for saddr and one for daddr. GeoIP will be the first drops # daddr if [[ $(${nftCommand} list chain ${nftFamily} ${nftTable} ${nftChain} | grep "ip daddr @GEOIP-${blockCountries[${i}]}") ]]; then : # found rule - do nothing else ${nftCommand} insert rule ${nftFamily} ${nftTable} ${nftChain} ip daddr @GEOIP-${blockCountries[${i}]} log prefix "DROP_GEOIP-${blockCountries[${i}]}_" counter drop comment "DROP_GEOIP-${blockCountries[${i}]}" fi # saddr if [[ $(${nftCommand} list chain ${nftFamily} ${nftTable} ${nftChain} | grep "ip saddr @GEOIP-${blockCountries[${i}]}") ]]; then : # found rule - do nothing else ${nftCommand} insert rule ${nftFamily} ${nftTable} ${nftChain} ip saddr @GEOIP-${blockCountries[${i}]} log prefix "DROP_GEOIP-${blockCountries[${i}]}_" counter drop comment "DROP_GEOIP-${blockCountries[${i}]}" fi
echo "Updating rules for GEOIP-${blockCountries[${i}]} complete" done
Note that this script does not remove entries from the list. It will only add to it. Any removal will need to be done manually. Hopefully this has been helpful.