Running a mail server on the public internet means dealing with a constant stream of brute-force attempts. Credential stuffers, password sprayers, and opportunistic bots hammer away at IMAP, submission ports, and webmail interfaces around the clock. While fail2ban-style rate limiting helps, the sheer volume of attempts still clutters logs and wastes resources on connection handling.
The solution I’ve implemented takes a different approach: geographic restriction. My users are all located in Central Europe - Germany, Austria, Switzerland, and neighboring countries. There’s no legitimate reason for someone in a botnet-heavy region to connect to my IMAP server. By restricting client-facing ports to IP ranges allocated to specific countries, I’ve dramatically reduced both attack surface and log noise.

The Strategy
The key insight is distinguishing between server-to-server and client-to-server traffic:
- SMTP (port 25) must remain globally accessible. Mail servers from anywhere in the world need to deliver messages to my server. Restricting this would break email.
- Client ports - IMAP (143), Submission (587), Sieve (4190), and webmail (80/443) - only need to be accessible from where my users actually are.
This creates a two-tier firewall policy: SMTP open to all, everything else restricted by geography.
Architecture Overview
Internet
|
v
+-----------------------+
| PF Firewall |
| |
| <geoip_users> table |
| ~273,000 CIDR blocks |
+-----------+-----------+
|
+---------------+---------------+
| |
v v
+----------+ +-----------+
| SMTP:25 | | Client |
| Open to | | Ports |
| everyone | | GeoIP |
+----------+ | filtered |
+-----------+
The <geoip_users> table contains approximately 273,000 CIDR blocks covering the allowed countries. PF evaluates incoming connections against this table before allowing access to restricted services.
MaxMind GeoLite2 Database
The geographic data comes from MaxMind’s GeoLite2 database, available free with registration. The CSV format works well for our purposes:
$ ls -lah /usr/local/share/GeoIP/GeoLite2-Country-CSV_20260106
total 11 MB
-rw-r--r-- 1 root wheel 22M Jan 6 07:42 GeoLite2-Country-Blocks-IPv4.csv
-rw-r--r-- 1 root wheel 27M Jan 6 07:42 GeoLite2-Country-Blocks-IPv6.csv
-rw-r--r-- 1 root wheel 9.6K Jan 6 07:42 GeoLite2-Country-Locations-en.csv
...
The database structure is straightforward:
- Locations files map geoname IDs to country codes and names
- Blocks files map CIDR ranges to geoname IDs
To find all IP ranges for Germany, you first look up Germany’s geoname ID in the locations file, then find all CIDR blocks with that ID in the blocks files.
The Update Script
Processing the GeoLite2 data and loading it into PF requires careful handling. With nearly 300,000 entries, you can’t just dump everything at once - PF will reject the operation with a memory error. The solution is chunked loading:
#!/bin/sh
# Configuration
GEOIP_DIR="/usr/local/share/GeoIP/GeoLite2-Country-CSV_20260106"
PF_FILE="/var/db/pf/geoip_allowed"
TEMP_OUT="/tmp/geoip_allowed.tmp"
TEMP_IDS="/tmp/target_ids.txt"
CHUNK_DIR="/tmp/geoip_chunks"
# Allowed countries (ISO Codes)
COUNTRIES="DE|AT|CH|NL|LU|FR"
# Cleanup
: > "$TEMP_OUT"
rm -rf "$CHUNK_DIR"
echo "Processing GeoIP data for: $COUNTRIES ..."
# Step 1: Extract geoname IDs for target countries
grep -E ",($COUNTRIES)," "$GEOIP_DIR/"*Locations-en.csv | cut -d, -f1 > "$TEMP_IDS"
# Step 2: Extract CIDRs from block files
# IPv4
grep -F -f "$TEMP_IDS" "$GEOIP_DIR/"*Blocks-IPv4.csv | cut -d, -f1 >> "$TEMP_OUT"
# IPv6
grep -F -f "$TEMP_IDS" "$GEOIP_DIR/"*Blocks-IPv6.csv | cut -d, -f1 >> "$TEMP_OUT"
if [ -s "$TEMP_OUT" ]; then
mkdir -p "$(dirname "$PF_FILE")"
mv "$TEMP_OUT" "$PF_FILE"
echo "Loading firewall table in 50k chunks..."
# Flush existing entries
pfctl -t geoip_users -T flush
# Split into manageable chunks
mkdir -p "$CHUNK_DIR"
split -l 50000 "$PF_FILE" "$CHUNK_DIR/chunk_"
# Load each chunk
for chunk in "$CHUNK_DIR"/chunk_*; do
pfctl -t geoip_users -T add -f "$chunk"
done
# Verify
COUNT=$(pfctl -t geoip_users -T show | wc -l | tr -d ' ')
echo "Success: $COUNT networks now active in memory."
# Cleanup
rm -rf "$CHUNK_DIR"
rm -f "$TEMP_IDS"
else
echo "Error: No data extracted."
rm -f "$TEMP_OUT"
exit 1
fi
The script processes both IPv4 and IPv6 ranges, creating a unified list that PF can use for dual-stack filtering.
Kernel Tuning
By default, PF has a relatively low limit on table entries. Loading 273,000 CIDR blocks requires increasing this limit via sysctl.conf:
# Allow large PF tables for GeoIP filtering
net.pf.request_maxcount=500000
This setting must be applied before loading the GeoIP data. After adding it to /etc/sysctl.conf, either reboot or apply it live:
sysctl net.pf.request_maxcount=500000
PF Configuration
Here’s the relevant portion of /etc/pf.conf showing how the GeoIP table integrates with the firewall rules:
# --- Macros ---
ext_if = "vtnet0"
jail_net = "10.0.0.0/24"
jail_net6 = "2001:db8:1c1c:4d2:8000::/65"
mail_ipv4 = "10.0.0.3"
mail_ipv6 = "2001:db8:1c1c:4d2:8000::3"
webmail_ipv6 = "2001:db8:1c1c:4d2:8000::4"
# --- Tables ---
table <bruteforce> persist
table <jails_v4> { $jail_net }
table <jails_v6> { $jail_net6 }
table <geoip_users> persist
# --- Options ---
set limit table-entries 1000000
set skip on lo0
set block-policy drop
set loginterface $ext_if
# --- Scrub ---
scrub in all fragment reassemble
scrub out on $ext_if random-id
scrub out all random-id max-mss 1500
# --- NAT for jails (IPv4 only) ---
nat on $ext_if inet from <jails_v4> to any -> ($ext_if)
# --- RDR for services ---
# Client ports: GeoIP restricted
rdr pass on $ext_if inet proto tcp from <geoip_users> \
to ($ext_if) port {143, 587, 4190} -> $mail_ipv4
# SMTP: Open to all (server-to-server)
rdr pass on $ext_if inet proto tcp from any \
to ($ext_if) port 25 -> $mail_ipv4
# --- Filtering ---
block quick from <bruteforce>
block drop in log all
block drop out log all
pass out quick all keep state
antispoof quick for { $ext_if, bridge0 }
# IPv6: Client ports with GeoIP restriction
pass in quick on $ext_if inet6 proto tcp from <geoip_users> \
to $mail_ipv6 port {143, 587, 4190} flags S/SA keep state
# IPv6: SMTP open to all
pass in quick on $ext_if inet6 proto tcp from any \
to $mail_ipv6 port 25 flags S/SA keep state
# IPv6: Webmail with GeoIP restriction
pass in quick on $ext_if inet6 proto tcp from <geoip_users> \
to $webmail_ipv6 port {80, 443} flags S/SA keep state
# Essential ICMPv6
pass in quick inet6 proto ipv6-icmp icmp6-type \
{ echoreq, echorep, neighbrsol, neighbradv, toobig, timex, paramprob }
# ICMP for IPv4
pass in inet proto icmp icmp-type { echoreq, unreach }
# Jail egress
pass in quick on { bridge0 } from <jails_v4> to ! 10.0.0.0/24 keep state
pass in quick on { bridge0 } inet6 from <jails_v6> \
to ! 2001:db8:1c1c:4d2:8000::/65 keep state
Note the set limit table-entries 1000000 directive - this is the PF-level configuration that complements the sysctl tuning.
The dual approach to traffic handling is visible in the rules:
- SMTP (port 25):
from any- no source restriction - Client ports (143, 587, 4190, 80, 443):
from <geoip_users>- restricted to allowed countries
Verifying the Setup
After loading the GeoIP data, verify the table is populated:
$ pfctl -t geoip_users -T show | wc -l
273460
$ pfctl -t geoip_users -T show | tail -n 5
2a1b:3e0:500::/64
2a1d:3e0:500::/64
2a1f:3e0:500::/64
2c0f:2c40:a310::/48
2c0f:fc04:2000::/64
The loaded rules can be verified with pfctl -s r:
pass in quick on vtnet0 inet6 proto tcp from <geoip_users> \
to 2001:db8:1c1c:4d2:8000::3 port = imap flags S/SA keep state
pass in quick on vtnet0 inet6 proto tcp from <geoip_users> \
to 2001:db8:1c1c:4d2:8000::3 port = submission flags S/SA keep state
pass in quick on vtnet0 inet6 proto tcp from any \
to 2001:db8:1c1c:4d2:8000::3 port = smtp flags S/SA keep state
The rules clearly show the differentiation: IMAP and submission require membership in <geoip_users>, while SMTP allows any source.
Automating Updates
MaxMind updates the GeoLite2 database weekly. A cron job keeps the firewall table current:
# Update GeoIP data weekly (Mondays at 3 AM)
0 3 * * 1 /usr/local/bin/update_geoip_maxmind.sh >> /var/log/geoip_update.log 2>&1
For the database download itself, MaxMind provides a geoipupdate tool that handles authentication and retrieval. The update script then processes the fresh data.
Results
The impact has been significant:
- Log volume: Brute-force attempt logs dropped by roughly 90%
- Connection overhead: Fewer TCP handshakes to reject means lower resource usage
- Signal-to-noise ratio: When something does appear in the logs, it’s more likely to be worth investigating
The remaining attempts typically come from compromised hosts within allowed countries or VPN exit nodes. These still hit rate limiting, but at a manageable volume.
Caveats
This approach isn’t without trade-offs:
- Traveling users: Anyone traveling outside the allowed countries will be blocked. Communicate this clearly to users, or consider adding VPN access as a bypass.
- VPN services: Users connecting through VPN providers with exit nodes outside allowed countries will be blocked.
- Database accuracy: GeoIP data isn’t perfect. Some IP ranges are misattributed, and mobile carriers sometimes route traffic through unexpected locations.
- Table size: 273,000 entries consume kernel memory. On memory-constrained systems, consider restricting to fewer countries.
For my use case - a small mail server with users who rarely travel - these trade-offs are acceptable.
Conclusion
Geographic filtering is a powerful layer in a defense-in-depth strategy. It doesn’t replace proper authentication, TLS, or rate limiting, but it dramatically reduces the attack surface by eliminating entire classes of attackers before they even reach the application layer.
PF’s table mechanism handles large GeoIP datasets efficiently, and the chunked loading approach works around memory limitations during updates. Combined with MaxMind’s regularly updated databases, this provides a low-maintenance way to keep your servers visible only where they need to be.
The bots scanning from halfway around the world? They now see nothing but a silent drop. Your logs stay clean, your resources stay available for legitimate users, and your threat surface shrinks to a fraction of what it was.
References
- MaxMind GeoLite2 Free Geolocation Data
- PF - The OpenBSD Packet Filter (FreeBSD’s PF derives from OpenBSD)
- FreeBSD Handbook: Firewalls
- FreeBSD pf.conf(5) man page
Sometimes the best security is simply not being there. If an attacker can’t reach your service, they can’t attack it. Geographic filtering makes your server invisible to most of the internet while remaining fully accessible to the people who actually need it.