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:
- GeoIP nftables table (
inet geoip): Independent table that runs at high priority to mark packets based on source country - Packet marking: Uses nftables maps to efficiently tag packets with country-specific marks
- Shorewall rules: Filters or rate-limits traffic based on the packet marks
Prerequisites
System Requirements
- Linux kernel with nftables support (kernel 3.13+, recommended 5.0+)
- Shorewall 5.2+ (with nftables backend)
- Python 3.9+
nftablespackage installed
Install nftables-geoip
Clone and set up the nftables-geoip tool with a virtualenv:
# Clone the repositorygit clone https://github.com/pvxe/nftables-geoip.git# Install to system location (run as root or with sudo)mv nftables-geoip /opt/nftables-geoipcd /opt/nftables-geoip# Create virtual environmentpython3 -m venv venvvenv/bin/pip install --upgrade pipvenv/bin/pip install requests# Make script executablechmod +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 mapsgeoip-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.nftdefine CN = 156 # Chinadefine RU = 643 # Russia# From geoip-ipv4-interesting.nftmap geoip4 {type ipv4_addr : markelements = {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/bashset -ecd /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 locationmv -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.nftEOFchmod +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.shEOF
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 classesPROVIDER_BITS=4 # Bits 8-11: Routing provider selectionPROVIDER_OFFSET=8 # This combined with MASK_BITS leaves bits 4-7 for general packet markingMASK_BITS=4 # Bits to mask off for Traffic ControlZONE_BITS=4 # Bits 12-15: Zone marking automatically
This creates the following bit layout:
GeoIP marks use bits 4-7 (the lower part of MASK_BITS):
| Mark Value | Countries | Binary (bits 7-0) | Hex |
|---|---|---|---|
| 0x10 | CN, RU (blocked) | 0001 0000 | 0x10 |
| 0x00 | Unmarked (allowed) | 0000 0000 | 0x00 |
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 markcat geoip-def-all.nft | awk '/define [A-Z][A-Z] =/ { print $1, $2, "= 0x10" }' > /etc/nftables/geoip-def-custom.nft# Verify the outputhead /etc/nftables/geoip-def-custom.nft
Example output:
define CN = 0x10define 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 geoipdelete table inet geoip# Create GeoIP filtering tabletable inet geoip {# Include generated GeoIP mapsinclude "geoip-ipv4-interesting.nft"include "geoip-ipv6-interesting.nft"# Mark incoming packets based on source IPchain 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 markmeta mark set ip saddr map @geoip4 countermeta 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 countermeta 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 countermeta 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-geoipcd /opt/nftables-geoipmv 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 permissionschmod 644 /etc/nftables/geoip-*.nftchmod 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/initchmod +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 tablesnft 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 tablenft 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 controlPROVIDER_BITS=4 # Bits 8-11 for policy routingPROVIDER_OFFSET=8 # Provider bits start at bit 8MASK_BITS=4 # Number of bits to mask for removing TCZONE_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 HELPERDROP: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 HELPERACCEPT 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 countriesdefine US = 0x10define CA = 0x20define GB = 0x30
# Drop everything NOT marked as allowed countryDROP:info:geo net $FW tcp 22 - - - - !0x10/0xF0DROP:info:geo net $FW tcp 22 - - - - !0x20/0xF0DROP: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 anywhereACCEPT net $FW tcp 80,443# SSH - block marked countries, rate-limit othersDROP:info:geo net $FW tcp 22 - - - - 0x10/0xF0ACCEPT net $FW tcp 22 - - 1/min:2# Database - block marked countries entirelyDROP:info:geo net $FW tcp 3306,5432 - - - - 0x10/0xF0# SMTP submission - allow from anywhere but with rate limitsACCEPT 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 DPORT1:F net $FW tcp 80 # HTTP gets TC class 12: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 configurationshorewall check# Reload rulesshorewall reload
Step 4: Testing and Validation
Verify Packet Marking
Test that packets are being marked correctly:
# Watch real-time packet counterswatch -n 1 'nft -nn list chain inet geoip geoip-mark-input'# View counter statistics with handle numbersnft -a list chain inet geoip geoip-mark-input# Reset counters to zeronft 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 3meta 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 IPnft add rule inet geoip geoip-mark-input ip saddr 8.8.8.8 meta mark set 0x10# Test SSH connectionssh user@your-server# Remove test rulenft delete rule inet geoip geoip-mark-input handle <handle-number>
Check Shorewall Logs
Monitor Shorewall's log for GeoIP drops:
# Watch for geo-tagged dropsjournalctl -f | grep geo# Or with traditional syslogtail -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 rapidlyfor i in {1..5}; dossh -o ConnectTimeout=2 user@your-server "echo attempt $i"sleep 1done
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 = 0x10define RU = 0x10define KP = 0x10define IR = 0x10# Medium-risk countries (rate-limited)define VN = 0x20define BR = 0x20define IN = 0x20# Trusted countries (no restrictions)define US = 0x30define CA = 0x30define GB = 0x30
# Block high-risk entirelyDROP:info:high-risk net $FW tcp 22 - - - - 0x10/0xF0# Rate-limit medium-riskACCEPT net $FW tcp 22 - - 1/min:2 - 0x20/0xF0# Allow trusted freelyACCEPT 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.shset -eTHREAT_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 filesmv -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 rulesACCEPT net:203.0.113.0/24 $FW tcp 22# Then apply GeoIP blockingDROP: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 = trueport = sshfilter = sshdlogpath = /var/log/auth.logmaxretry = 3bantime = 3600findtime = 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.confif $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 existsnft list tables | grep geoip# Check if maps are loadednft list map inet geoip geoip4# Verify chain hooksnft -a list chain inet geoip geoip-mark-input
Solutions:
- Check file paths in include statements
- 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 correctlynft list table ip filter | grep 'mark & 0x000000f0 != 0x00000010'
Solutions:
- Check rule syntax in
/etc/shorewall/rules - 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 IPwhois 203.0.113.1 | grep -i country# Verify IP is in GeoIP maps; returns "file not found" if it's not therenft get element inet geoip geoip4 { 203.0.113.1 }
Solutions:
- GeoIP data may be outdated - run update script
- Some IPs are misclassified - whitelist specific IPs
- 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 usagenft list ruleset | wc -l# Check map sizesnft list map inet geoip geoip4 | wc -l
Solutions:
- Reduce number of countries tracked
- Use IPv4-only if IPv6 not needed
- Consider using sets instead of maps for simple blocking:
set blocklist4 {type ipv4_addrflags intervalelements = { 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 traffichping3 -S -p 22 -c 10000 --faster your-server# Monitor nftables performancenft monitor
Optimization Tips
- Limit countries: Only track countries you actually want to filter
- Use sets for simple blocking: If you don't need per-country actions, use sets instead of maps
- IPv4-only when possible: IPv6 maps double memory usage
- Combine adjacent ranges: The nftables-geoip tool does this automatically
Memory Usage
Typical memory usage per country:
| Regions | IPv4 Ranges | IPv6 Ranges | Memory 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:
- Use GeoIP as one layer of defense, not the only layer
- Combine with rate limiting, fail2ban, and authentication
- Whitelist known-good IPs regardless of country
- 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:
- GeoIP filtering (reduce noise)
- Rate limiting (slow down attacks)
- Strong authentication (prevent unauthorized access)
- Intrusion detection (detect anomalies)
- Regular patching (fix vulnerabilities)