GeoIP Filtering with Shorewall and nftables

Introduction

This guide demonstrates how to combine Shorewall firewall management with GeoIP-based filtering using nftables.

This approach is particularly useful for:

  • Blocking brute-force SSH attacks from specific countries
  • Reducing automated scanning traffic
  • Implementing geographic access controls
  • Reducing log noise from unwanted traffic

Shorewall only supports iptables and modern Linux distributions use iptables-nft to convert iptables format commands into nftables rules. In the iptables-based past, there was a xtables-addons loadable kernel module called xt_geoip.ko which Shorewall had support for to block a country directly in the Shorewall rules file. This replaces that approach by combining nftables concept of sets with Shorewall's rules files.

Architecture Overview

The solution consists of three layers:

  1. GeoIP nftables table (inet geoip): Independent table that runs at high priority to mark packets based on source country
  2. Packet marking: Uses nftables maps to efficiently tag packets with country-specific marks
  3. Shorewall rules: Filters or rate-limits traffic based on the packet marks
geoip_flow packet Incoming Packet geoip nftables inet geoip table (priority -1) • Lookup source IP in GeoIP maps • Set packet mark based on country  (0x10 = blocked countries) packet->geoip shorewall Shorewall rules (normal priority) • DROP packets with mark 0x10/0xF0 • Rate-limit SSH except marked packets geoip->shorewall decision Accept or Drop shorewall->decision

Prerequisites

System Requirements

  • Linux kernel with nftables support (kernel 3.13+, recommended 5.0+)
  • Shorewall 5.2+ (with nftables backend)
  • Python 3.9+
  • nftables package installed

Install nftables-geoip

Clone and set up the nftables-geoip tool with a virtualenv:

# Clone the repository
git clone https://github.com/pvxe/nftables-geoip.git
# Install to system location (run as root or with sudo)
mv nftables-geoip /opt/nftables-geoip
cd /opt/nftables-geoip
# Create virtual environment
python3 -m venv venv
venv/bin/pip install --upgrade pip
venv/bin/pip install requests
# Make script executable
chmod +x nft_geoip.py

This tool downloads IP ranges from IPdeny and generates nftables map definitions. Requires Python 3.9 minimum.

Step 1: Generate GeoIP Data

Generate nftables maps for the countries you want to filter. For this example, we'll block China (CN) and Russia (RU):

# Generate GeoIP maps for China and Russia (run as root)
# -c/--country-filter: country codes (comma-separated)
# --download: download fresh data
/opt/nftables-geoip/venv/bin/python /opt/nftables-geoip/nft_geoip.py --country-filter ru,cn --download

This creates several files:

  • geoip-def-all.nft: Country variable definitions (e.g., define CN = 156)
  • geoip-ipv4-interesting.nft: Filtered IPv4 address maps
  • geoip-ipv6-interesting.nft: Filtered IPv6 address maps

Generated Map Structure

The tool generates country code definitions based on ISO 3166-1 numeric codes:

# From geoip-def-all.nft
define CN = 156 # China
define RU = 643 # Russia
# From geoip-ipv4-interesting.nft
map geoip4 {
type ipv4_addr : mark
elements = {
1.0.1.0/24 : $CN,
1.0.2.0/23 : $CN,
5.3.192.0/22 : $RU,
5.8.0.0/19 : $RU,
# ... thousands more entries
}
}

Each IP range is mapped to its country's ISO numeric code. However, for Shorewall integration, we'll override these with custom hex values in bits 4-7 to avoid conflicts with Shorewall's packet marking scheme.

Updating GeoIP Data

IP address allocations change over time. Set up a cron job to update monthly:

# Create update script (run as root)
cat > /usr/local/bin/update-geoip.sh <<'EOF'
#!/bin/bash
set -e
cd /opt/nftables-geoip
/opt/nftables-geoip/venv/bin/python /opt/nftables-geoip/nft_geoip.py --country-filter ru,cn --download
# Move updated files to system location
mv -f geoip-ipv4-interesting.nft /etc/nftables/
mv -f geoip-ipv6-interesting.nft /etc/nftables/
mv -f geoip-def-all.nft /etc/nftables/
# Reload the GeoIP table
/etc/nftables/geoip-filter.nft
EOF
chmod +x /usr/local/bin/update-geoip.sh
# Add to cron (run on first of month)
cat > /etc/cron.d/geoip-update <<'EOF'
0 3 1 * * root /usr/local/bin/update-geoip.sh
EOF

Step 2: Create nftables GeoIP Table

Create an independent nftables table that marks packets based on their source country. This table runs at priority -1 (before Shorewall's rules).

Understanding Packet Marks

Packet marks are 32-bit values used to tag packets for later filtering. Shorewall reserves the high 16-bit for future use and divides the rest of the 16-bits of the mark into different bit fields for different purposes. With the configuration used here:

TC_BITS=4 # Bits 0-3: Traffic control classes
PROVIDER_BITS=4 # Bits 8-11: Routing provider selection
PROVIDER_OFFSET=8 # This combined with MASK_BITS leaves bits 4-7 for general packet marking
MASK_BITS=4 # Bits to mask off for Traffic Control
ZONE_BITS=4 # Bits 12-15: Zone marking automatically

This creates the following bit layout:

packet_mark_layout mark 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0 zone Zone (bits 12-15) mark:s->zone:n provider Provider (bits 8-11) mark:s->provider:n geoip GeoIP (bits 4-7) mark:s->geoip:n tc Traffic Control (bits 0-3) mark:s->tc:n

GeoIP marks use bits 4-7 (the lower part of MASK_BITS):

Mark ValueCountriesBinary (bits 7-0)Hex
0x10CN, RU (blocked)0001 00000x10
0x00Unmarked (allowed)0000 00000x00

The mask 0xF0 (binary 11110000) isolates bits 4-7, allowing us to:

  • Match the GeoIP mark (0x10 in bits 4-7)
  • Leave bits 0-3 available for traffic control (TC) classes
  • Leave bits 8-11 available for provider-based routing
  • Leave bits 12-15 available for source zone tracking

Why use the same mark for all countries? Since China and Russia will be handled identically by Shorewall rules (both blocked or rate-limited the same way), there's no need to distinguish between them at the packet level. This simplifies both the nftables configuration and Shorewall rules.

Mark compatibility: Because GeoIP uses bits 4-7, you can still:

  • Apply TC classes (bits 0-3): e.g., 0x13 = GeoIP blocked + TC class 3
  • Use provider routing (bits 8-9): e.g., 0x110 = GeoIP blocked + provider 1
  • Combine all three: 0x113 = GeoIP blocked + provider 1 + TC class 3

Override Country Definitions

The generated geoip-def-all.nft contains ISO 3166-1 numeric codes (e.g., CN=156, RU=643). For Shorewall integration, override these with the custom hex value 0x10:

# Redefine all countries to use 0x10 mark
cat geoip-def-all.nft | awk '/define [A-Z][A-Z] =/ { print $1, $2, "= 0x10" }' > /etc/nftables/geoip-def-custom.nft
# Verify the output
head /etc/nftables/geoip-def-custom.nft

Example output:

define CN = 0x10
define RU = 0x10

Create the nftables Script

Create /etc/nftables/geoip-filter.nft:

#!/usr/sbin/nft -f
# Include custom country definitions (overrides ISO numeric codes with 0x10)
include "geoip-def-custom.nft"
# Ensure table exists, then delete it (idempotent)
table inet geoip
delete table inet geoip
# Create GeoIP filtering table
table inet geoip {
# Include generated GeoIP maps
include "geoip-ipv4-interesting.nft"
include "geoip-ipv6-interesting.nft"
# Mark incoming packets based on source IP
chain geoip-mark-input {
# Run at priority -1 (before standard filtering)
type filter hook input priority -1; policy accept;
# Look up source IP in GeoIP maps and set mark
meta mark set ip saddr map @geoip4 counter
meta mark set ip6 saddr map @geoip6 counter
}
}

Alternative: Output Chain Filtering

If you also want to filter outbound connections (e.g., prevent connections to foreign C2 servers):

table inet geoip {
include "/etc/nftables/geoip-ipv4-interesting.nft"
include "/etc/nftables/geoip-ipv6-interesting.nft"
chain geoip-mark-input {
type filter hook input priority -1; policy accept;
meta mark set ip saddr map @geoip4 counter
meta mark set ip6 saddr map @geoip6 counter
}
chain geoip-mark-output {
type filter hook output priority -1; policy accept;
meta mark set ip daddr map @geoip4 counter
meta mark set ip6 daddr map @geoip6 counter
}
}

Move Files to System Location

# Create directory structure (run as root)
mkdir -p /etc/nftables
# Move generated GeoIP maps from /opt/nftables-geoip
cd /opt/nftables-geoip
mv geoip-ipv4-interesting.nft /etc/nftables/
mv geoip-ipv6-interesting.nft /etc/nftables/
mv geoip-def-all.nft /etc/nftables/
# Copy the filter script (create this manually, see below)
cp geoip-filter.nft /etc/nftables/
# Set permissions
chmod 644 /etc/nftables/geoip-*.nft
chmod 755 /etc/nftables/geoip-filter.nft

Configure Shorewall Hooks

Use Shorewall's built-in hooks to manage the GeoIP table lifecycle.

Create /etc/shorewall/init:

#!/bin/bash
# Load GeoIP table before Shorewall starts
/etc/nftables/geoip-filter.nft

Create /etc/shorewall/stopped:

#!/bin/bash
# Clean up GeoIP table when Shorewall stops
/usr/sbin/nft delete table inet geoip 2>/dev/null || true

Make them executable:

chmod +x /etc/shorewall/init
chmod +x /etc/shorewall/stopped

The init hook runs before Shorewall processes any configuration, ensuring GeoIP marks are available when rules are evaluated. The stop hook cleans up the table when Shorewall stops.

Verify nftables Table

# List all tables
nft list tables
# Should show added table plus Shorewall tables:
# table ip6 raw
# table ip6 nat
# table ip6 mangle
# table ip6 filter
# table inet geoip
# table ip raw
# table ip nat
# table ip mangle
# table ip filter
# Inspect GeoIP table
nft list table inet geoip

Step 3: Configure Shorewall

Configure Packet Mark Layout

First, configure Shorewall's packet mark bit allocation in /etc/shorewall/shorewall.conf:

################################################################################
# P A C K E T M A R K L A Y O U T
################################################################################
TC_BITS=4 # Bits 0-3 for traffic control
PROVIDER_BITS=4 # Bits 8-11 for policy routing
PROVIDER_OFFSET=8 # Provider bits start at bit 8
MASK_BITS=4 # Number of bits to mask for removing TC
ZONE_BITS=4 # Track zones

This configuration ensures GeoIP marks (using bits 4-7 with 0x10/0xF0) don't conflict with traffic control, provider routing, or zone marking.

Configure Shorewall Rules

Now that packets are marked with country codes, configure Shorewall to filter based on these marks.

Understanding Shorewall Mark Syntax

Shorewall's mark column uses the format mark/mask:

  • 0x10/0xF0: Match packets where (mark & 0xF0) == 0x10
  • !0x10/0xF0: Match packets where (mark & 0xF0) != 0x10

Example: Block All GeoIP-Marked Traffic

In /etc/shorewall/rules:

Drop all traffic from blocked countries with logging:

#ACTION SOURCE DEST PROTO DPORT SPORT ORIGDEST RATE USER MARK CONNLIMIT TIME HEADERS SWITCH HELPER
DROP:info:geo net $FW - - - - - - 0x10/0xF0

This logs dropped packets with the prefix geo in syslog. Since all blocked countries use the same mark (0x10), only one rule is needed.

Example: Rate-Limit SSH from Allowed Countries

Don't allow SSH from blocked countries and rate-limit all others:

#ACTION SOURCE DEST PROTO DPORT SPORT ORIGDEST RATE USER MARK CONNLIMIT TIME HEADERS SWITCH HELPER
ACCEPT net $FW tcp 22 - - 1/min:2 - !0x10/0xF0

How the rate limit works:

  • !0x10/0xF0: Only match unmarked traffic (e.g., not from blocked countries)
  • 1/min:2: Allow 1 connection per minute, with a burst of 2
  • After 2 connections, further attempts are blocked until rate drops below 1/min

Example: Allow Only Specific Countries

Invert the logic to allow only certain countries (whitelist approach):

# Modify geoip-filter.nft to mark allowed countries
define US = 0x10
define CA = 0x20
define GB = 0x30
# Drop everything NOT marked as allowed country
DROP:info:geo net $FW tcp 22 - - - - !0x10/0xF0
DROP:info:geo net $FW tcp 22 - - - - !0x20/0xF0
DROP:info:geo net $FW tcp 22 - - - - !0x30/0xF0

Example: Multi-Level Security

Different services with different geographic restrictions:

#ACTION SOURCE DEST PROTO DPORT SPORT ORIGDEST RATE USER MARK CONNLIMIT TIME HEADERS SWITCH HELPER
# Public web server - allow from anywhere
ACCEPT net $FW tcp 80,443
# SSH - block marked countries, rate-limit others
DROP:info:geo net $FW tcp 22 - - - - 0x10/0xF0
ACCEPT net $FW tcp 22 - - 1/min:2
# Database - block marked countries entirely
DROP:info:geo net $FW tcp 3306,5432 - - - - 0x10/0xF0
# SMTP submission - allow from anywhere but with rate limits
ACCEPT net $FW tcp 587 - - 5/min:10 - -

Example: Combining GeoIP with Traffic Control

Since GeoIP uses bits 4-7 and TC uses bits 0-3, you can combine them:

# In /etc/shorewall/tcrules
#MARK SOURCE DEST PROTO DPORT
1:F net $FW tcp 80 # HTTP gets TC class 1
2:F net $FW tcp 443 # HTTPS gets TC class 2
# These can be combined with GeoIP marks
# A packet from China on port 80 would have mark: 0x11 (0x10 GeoIP + 0x01 TC)
# A packet from China on port 443 would have mark: 0x12 (0x10 GeoIP + 0x02 TC)

To match GeoIP regardless of TC class in rules:

# This still works because mask 0xF0 ignores the lower 4 bits (TC)
DROP:info:geo net $FW tcp 22 - - - - 0x10/0xF0

Reload Shorewall

After modifying rules:

# Check configuration
shorewall check
# Reload rules
shorewall reload

Step 4: Testing and Validation

Verify Packet Marking

Test that packets are being marked correctly:

# Watch real-time packet counters
watch -n 1 'nft -nn list chain inet geoip geoip-mark-input'
# View counter statistics with handle numbers
nft -a list chain inet geoip geoip-mark-input
# Reset counters to zero
nft reset counters table inet geoip

The counters show how many packets and bytes have been processed by each rule. Example output:

table inet geoip {
chain geoip-mark-input {
type filter hook input priority -1; policy accept;
meta mark set ip saddr map @geoip4 counter packets 1523 bytes 94384 # handle 3
meta mark set ip6 saddr map @geoip6 counter packets 0 bytes 0 # handle 4
}
}

Simulate GeoIP Traffic

Use nft to manually mark packets for testing:

# Add temporary test rule to mark all traffic from specific IP
nft add rule inet geoip geoip-mark-input ip saddr 8.8.8.8 meta mark set 0x10
# Test SSH connection
ssh user@your-server
# Remove test rule
nft delete rule inet geoip geoip-mark-input handle <handle-number>

Check Shorewall Logs

Monitor Shorewall's log for GeoIP drops:

# Watch for geo-tagged drops
journalctl -f | grep geo
# Or with traditional syslog
tail -f /var/log/messages | grep geo

Example log entry:

Nov 15 10:23:45 server kernel: Shorewall:net-fw:DROP:geo IN=eth0 OUT= MAC=... SRC=123.45.67.89 DST=203.3.113.1 LEN=52 TOS=0x00 PREC=0x00 TTL=48 ID=38090 DF PROTO=TCP SPT=25946 DPT=22 WINDOW=42340 RES=0x00 SYN URGP=0 MARK=0x10

Test Rate Limiting

From a trusted IP (not marked as CN/RU):

# Attempt multiple SSH connections rapidly
for i in {1..5}; do
ssh -o ConnectTimeout=2 user@your-server "echo attempt $i"
sleep 1
done

Expected behavior:

  • First 2 attempts: succeed
  • Attempts 3-5: blocked (rate limit exceeded)

Advanced Configurations

Multiple Country Groups

Use different mark ranges for different policy groups:

# High-risk countries (blocked completely)
define CN = 0x10
define RU = 0x10
define KP = 0x10
define IR = 0x10
# Medium-risk countries (rate-limited)
define VN = 0x20
define BR = 0x20
define IN = 0x20
# Trusted countries (no restrictions)
define US = 0x30
define CA = 0x30
define GB = 0x30
# Block high-risk entirely
DROP:info:high-risk net $FW tcp 22 - - - - 0x10/0xF0
# Rate-limit medium-risk
ACCEPT net $FW tcp 22 - - 1/min:2 - 0x20/0xF0
# Allow trusted freely
ACCEPT net $FW tcp 22 - - - - 0x30/0xF0

Dynamic Country List Updates

Create a script that updates countries based on threat intelligence:

#!/bin/bash
# /usr/local/bin/update-threat-countries.sh
set -e
THREAT_COUNTRIES=$(curl -s https://threat-feed.example.com/countries)
cd /opt/nftables-geoip
/opt/nftables-geoip/venv/bin/python /opt/nftables-geoip/nft_geoip.py --country-filter "$THREAT_COUNTRIES" --download
# Move updated files
mv -f geoip-ipv4-interesting.nft /etc/nftables/
mv -f geoip-ipv6-interesting.nft /etc/nftables/
mv -f geoip-def-all.nft /etc/nftables/
# Reload the GeoIP table
/etc/nftables/geoip-filter.nft

Exclude Specific IPs from GeoIP Blocking

Whitelist specific IPs before GeoIP checks:

# Whitelist specific IP ranges before GeoIP rules
ACCEPT net:203.0.113.0/24 $FW tcp 22
# Then apply GeoIP blocking
DROP:info:geo net $FW tcp 22 - - - - 0x10/0xF0

Integration with fail2ban

Combine GeoIP filtering with fail2ban for multi-layer protection:

# /etc/fail2ban/jail.local
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 3600
findtime = 600
# GeoIP countries already blocked at firewall level
# This catches remaining threats

Logging to Separate File

Create dedicated GeoIP log:

# /etc/rsyslog.d/30-geoip.conf
if $msg contains "Shorewall:" and $msg contains ":geo:" then /var/log/geoip-blocks.log

Reload rsyslog:

systemctl restart rsyslog

Monitor:

tail -f /var/log/geoip-blocks.log

Troubleshooting

Issue: Packets Not Being Marked

Symptoms: No packets match GeoIP rules in Shorewall

Diagnosis:

# Check if nftables-geoip table exists
nft list tables | grep geoip
# Check if maps are loaded
nft list map inet geoip geoip4
# Verify chain hooks
nft -a list chain inet geoip geoip-mark-input

Solutions:

  1. Check file paths in include statements
  2. Verify nftables syntax: nft -c -f /etc/nftables/geoip-filter.nft

Issue: Shorewall Ignoring Marks

Symptoms: Rules with mark matchers don't trigger

Diagnosis:

# Verify mark rules compiled correctly
nft list table ip filter | grep 'mark & 0x000000f0 != 0x00000010'

Solutions:

  1. Check rule syntax in /etc/shorewall/rules
  2. Verify mark mask: use hex format 0x10/0xF0

Issue: Legitimate Traffic Blocked

Symptoms: Users from allowed countries can't connect

Diagnosis:

# Check which country owns an IP
whois 203.0.113.1 | grep -i country
# Verify IP is in GeoIP maps; returns "file not found" if it's not there
nft get element inet geoip geoip4 { 203.0.113.1 }

Solutions:

  1. GeoIP data may be outdated - run update script
  2. Some IPs are misclassified - whitelist specific IPs
  3. VPN/proxy users appear from different countries - provide alternative access

Issue: High Memory Usage

Symptoms: System using excessive RAM after loading GeoIP maps

Diagnosis:

# Check nftables memory usage
nft list ruleset | wc -l
# Check map sizes
nft list map inet geoip geoip4 | wc -l

Solutions:

  1. Reduce number of countries tracked
  2. Use IPv4-only if IPv6 not needed
  3. Consider using sets instead of maps for simple blocking:
set blocklist4 {
type ipv4_addr
flags interval
elements = { 1.0.1.0/24, 1.0.2.0/23, ... }
}
chain geoip-filter-input {
type filter hook input priority -1; policy accept;
ip saddr @blocklist4 drop
}

Performance Considerations

Map Lookup Efficiency

nftables uses efficient data structures for map lookups:

  • Small maps (fewer than 100 elements): Linear search, very fast
  • Large maps (>1000 elements): Hash table or tree, O(log n) lookup
  • Interval maps (CIDR ranges): Optimized interval tree

GeoIP maps typically contain 5,000-50,000 entries per country, making lookup performance critical.

Benchmark

Test lookup performance:

# Generate test traffic
hping3 -S -p 22 -c 10000 --faster your-server
# Monitor nftables performance
nft monitor

Optimization Tips

  1. Limit countries: Only track countries you actually want to filter
  2. Use sets for simple blocking: If you don't need per-country actions, use sets instead of maps
  3. IPv4-only when possible: IPv6 maps double memory usage
  4. Combine adjacent ranges: The nftables-geoip tool does this automatically

Memory Usage

Typical memory usage per country:

RegionsIPv4 RangesIPv6 RangesMemory Usage
China~7,000~1,500~2 MB
Russia~5,000~1,000~1.5 MB
USA~30,000~5,000~8 MB
Total (3 countries)~42,000~7,500~11.5 MB

For reference, blocking 10 countries typically uses 30-50 MB of RAM.

Security Considerations

Limitations of GeoIP Filtering

Limitations:

  • VPNs and proxies bypass GeoIP filtering
  • IP addresses can be misclassified
  • Cloud providers (AWS, GCP, Azure) have global IP ranges
  • Mobile networks may route through unexpected countries

Best Practices:

  1. Use GeoIP as one layer of defense, not the only layer
  2. Combine with rate limiting, fail2ban, and authentication
  3. Whitelist known-good IPs regardless of country
  4. Monitor logs for false positives

Defense-in-Depth Approach

Blocking countries alone does not prevent:

  • Compromised machines in allowed countries
  • Distributed botnets spanning multiple countries
  • Sophisticated attackers using proxies
  • Zero-day exploits

Implement multiple layers of security:

  1. GeoIP filtering (reduce noise)
  2. Rate limiting (slow down attacks)
  3. Strong authentication (prevent unauthorized access)
  4. Intrusion detection (detect anomalies)
  5. Regular patching (fix vulnerabilities)

References