Who logged in from which country? Geolocating SSH logins without a MaxMind licence
The five RIRs (RIPE, ARIN, APNIC, AFRINIC, LACNIC) publish for free which IP block is allocated to which country. With one 30 MB download and forty lines of shell you see the country of every SSH login, and alert on "this user has never logged in from that country". No MaxMind, no API key, no data leaving the host.
Contents
"A successful login from an unknown IP" is a mediocre alert: IPs change, colleagues work from hotels, the VPN gets a new address. "A successful login by this user from a country they've never come from" is a sharp one. That needs GeoIP, and the reflex is MaxMind — with a licence, an account, and since 2019 an EULA asking you not to redistribute their data. It can be done without. The five regional internet registries publish daily which address block is allocated to which country. It's not city-level, but for "which country" it's the source MaxMind itself starts from.
Step 1: fetch the RIR files
Every RIR publishes a delegated-<rir>-extended-latest file: one line per allocated block, with country code, start address and address count.
sudo install -d /var/lib/geoip
sudo tee /usr/local/sbin/geoip-fetch.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
cd /var/lib/geoip || exit 1
for u in \
https://ftp.ripe.net/pub/stats/ripencc/delegated-ripencc-extended-latest \
https://ftp.arin.net/pub/stats/arin/delegated-arin-extended-latest \
https://ftp.apnic.net/stats/apnic/delegated-apnic-extended-latest \
https://ftp.afrinic.net/pub/stats/afrinic/delegated-afrinic-extended-latest \
https://ftp.lacnic.net/pub/stats/lacnic/delegated-lacnic-extended-latest; do
curl -sS -m 120 -o "$(basename "$u").new" "$u" && mv "$(basename "$u").new" "$(basename "$u")" || echo "FAILED $u"
done
EOF
sudo chmod 0755 /usr/local/sbin/geoip-fetch.sh
sudo /usr/local/sbin/geoip-fetch.sh
ls -la /var/lib/geoip; head -3 /var/lib/geoip/delegated-ripencc-extended-latest
# ripencc|PS|ipv4|1.178.112.0|4096|20071126|allocated|71fbe5e6-...
Format: registry|cc|type|start|value|date|status|opaque-id. For IPv4, value is the number of addresses in the block; for IPv6 it's the prefix length. Only lines with status allocated or assigned are in use.
Step 2: build one lookup table
Convert all five files to start_int end_int cc, sorted. A lookup is then a single awk over 200,000 lines — tens of milliseconds.
sudo tee /usr/local/sbin/geoip-build.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
# Builds /var/lib/geoip/v4.tsv (start end cc) from the five RIR files.
cd /var/lib/geoip || exit 1
cat delegated-*-extended-latest \
| awk -F'|' '$3=="ipv4" && ($7=="allocated" || $7=="assigned") && $2!="" && $2!="*" {
split($4,o,"."); s=o[1]*16777216+o[2]*65536+o[3]*256+o[4];
printf "%d\t%d\t%s\n", s, s+$5-1, $2 }' \
| sort -n > v4.tsv.new && mv v4.tsv.new v4.tsv
# IPv6: prefix as text, cc — we match on prefix length in the lookup
cat delegated-*-extended-latest \
| awk -F'|' '$3=="ipv6" && ($7=="allocated" || $7=="assigned") && $2!="" && $2!="*" { printf "%s/%s\t%s\n", $4, $5, $2 }' \
> v6.tsv.new && mv v6.tsv.new v6.tsv
wc -l v4.tsv v6.tsv
EOF
sudo chmod 0755 /usr/local/sbin/geoip-build.sh
sudo /usr/local/sbin/geoip-build.sh
# v4.tsv ~ 200,000 lines
And the lookup itself:
sudo tee /usr/local/bin/geoip >/dev/null <<'EOF'
#!/usr/bin/env bash
# geoip <ip> → country code (ISO 3166-1 alpha-2) or "--"
ip=$1
if [[ $ip == *:* ]]; then
# IPv6: longest matching prefix wins
python3 - "$ip" <<'PY'
import sys, ipaddress
ip = ipaddress.ip_address(sys.argv[1])
best = ("--", -1)
for line in open("/var/lib/geoip/v6.tsv"):
net, cc = line.rstrip("\n").split("\t")
n = ipaddress.ip_network(net, strict=False)
if ip in n and n.prefixlen > best[1]: best = (cc, n.prefixlen)
print(best[0])
PY
exit
fi
IFS=. read -r a b c d <<<"$ip"
n=$(( a*16777216 + b*65536 + c*256 + d ))
awk -v n="$n" '$1<=n && n<=$2 {print $3; found=1; exit} END{if(!found) print "--"}' /var/lib/geoip/v4.tsv
EOF
sudo chmod 0755 /usr/local/bin/geoip
geoip 193.191.245.1 # BE
geoip 8.8.8.8 # US
geoip 10.0.0.1 # -- (private, not allocated)
Refresh the table weekly; the files change daily but allocations move slowly:
echo '20 4 * * 0 root /usr/local/sbin/geoip-fetch.sh && /usr/local/sbin/geoip-build.sh' | sudo tee /etc/cron.d/geoip
Step 3: add a country to every successful login
On Ubuntu 24.04 and Debian 12 the logins are in the journal. One line per login with time, user, IP and country:
journalctl -u ssh --since "30 days ago" --no-pager -o short-iso | grep -E 'Accepted (publickey|password)' \
| awk '{print $1, $7, $9}' \
| while read -r ts user ip; do printf '%s\t%s\t%s\t%s\n' "$ts" "$user" "$ip" "$(geoip "$ip")"; done \
| tee /var/lib/geoip/logins-30d.tsv | column -t
# 2026-09-12T08:14:02+0200 deploy 193.191.245.7 BE
# 2026-09-13T22:41:17+0200 jeroen 185.107.56.12 NL
# 2026-09-14T03:02:55+0200 deploy 45.155.205.233 RU ← this is what you want an alert on
Per user the pattern of the last 90 days — that's your baseline:
awk -F'\t' '{c[$2 FS $4]++} END{for(k in c) print k, c[k]}' /var/lib/geoip/logins-30d.tsv | sort
# deploy BE 212
# jeroen BE 48
# jeroen NL 3
Step 4: the alert — new country for this user
sudo tee /usr/local/sbin/geo-login-watch.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
NTFY="https://ntfy.example.be/security"
HOST=$(hostname -s)
BASE=/var/lib/geoip/baseline.tsv # user<TAB>cc, built from history
ALLOW=/etc/geoip-allowed-countries # optional: countries that are fine for everyone (BE, NL, ...)
touch "$BASE"
MSG=()
while read -r ts user ip; do
cc=$(geoip "$ip")
grep -qx "$cc" "$ALLOW" 2>/dev/null && continue
if ! grep -q "^${user} ${cc}$" "$BASE"; then
MSG+=("NEW COUNTRY: $user from $cc ($ip) at $ts")
printf '%s\t%s\n' "$user" "$cc" >> "$BASE" # report once, then it's baseline
fi
done < <(journalctl -u ssh --since "15 min ago" --no-pager -o short-iso | grep -E 'Accepted (publickey|password)' | awk '{print $1, $7, $9}')
[ ${#MSG[@]} -gt 0 ] && printf '%s\n' "${MSG[@]}" | curl -s -H "Title: geo-login $HOST" -H "Priority: urgent" --data-binary @- "$NTFY" >/dev/null
EOF
sudo chmod 0755 /usr/local/sbin/geo-login-watch.sh
# Seed the baseline with the last 90 days BEFORE enabling the cron — otherwise day one is one big alert
journalctl -u ssh --since "90 days ago" --no-pager -o short-iso | grep -E 'Accepted (publickey|password)' | awk '{print $7, $9}' \
| while read -r u ip; do printf '%s\t%s\n' "$u" "$(geoip "$ip")"; done | sort -u | sudo tee /var/lib/geoip/baseline.tsv >/dev/null
echo '*/15 * * * * root /usr/local/sbin/geo-login-watch.sh' | sudo tee /etc/cron.d/geo-login-watch
The choice to add the country to the baseline after one report is deliberate: the alert means "something new", not "something forbidden". If you want to hard-block a country, that's a firewall rule (step 5), not an alert.
Step 5: blocking countries at the firewall (optional, sparingly)
If your organisation never expects legitimate traffic from certain countries, you can refuse them on port 22. With nftables and a set:
# All IPv4 blocks of two countries as an nft set
awk -F'\t' '$3=="RU"||$3=="KP" {print $1, $2}' /var/lib/geoip/v4.tsv \
| python3 -c '
import sys, ipaddress
for line in sys.stdin:
s, e = map(int, line.split())
for n in ipaddress.summarize_address_range(ipaddress.IPv4Address(s), ipaddress.IPv4Address(e)): print(n)' \
> /tmp/blocked.txt
sudo nft add table inet geo 2>/dev/null
sudo nft add set inet geo blocked '{ type ipv4_addr; flags interval; }'
sudo nft add element inet geo blocked "{ $(paste -sd, /tmp/blocked.txt) }"
sudo nft add chain inet geo input '{ type filter hook input priority -10; }'
sudo nft add rule inet geo input tcp dport 22 ip saddr @blocked drop
Do this only for SSH and only if you're sure you won't lock out anyone who belongs — a colleague travelling is also "traffic from another country". Country-level blocking is no defence against a targeted attacker (they rent a VPS in Amsterdam), but it's a good noise filter.
Pitfalls
- VPNs and cloud IPs. A login via a corporate VPN gets the country of the VPN exit; a login from an AWS instance the country of that region. That's not a data error, it's reality — and exactly why "new country for this user" works better than a fixed allowlist.
allocatedversusassigned. Both are in use;availableandreservedaren't. The script filters on that.- Accuracy. RIR data says which country a block is allocated to, not where the address is physically used right now. For a multinational with one block that can differ. For the question "is this normal for this user" that's irrelevant: the baseline learns the pattern regardless of whether it's "correct".
- IPv6. The Python lookup is slow (reads the whole file per call). For dozens of logins a day that's fine; for thousands you rewrite it to a sorted prefix table.
- Journal retention. A 90-day baseline needs 90 days of journal. Set
MaxRetentionSec=90dayinjournald.confor keeplogins-30d.tsvcumulatively.
What you still don't have
- Fleet baseline. User
jeroenwho always comes from BE on server A and suddenly from RU on server B — you only see that if both servers share the same baseline. - Correlation with what follows. A new-country login followed by a honeypot read or a
sudowithin two minutes is a different story from a new-country login that does nothing else. - Maintenance. Five downloads, a build script and a baseline per host: it works, until the day an RIR changes its URL and nobody notices.
How monsys does it
The monsys agent does the same GeoIP lookup locally, with the same RIR data (the hub distributes a weekly-updated table to the agents — no IP goes to an external service). The new_country_login detection is per user and per tenant, so the baseline applies across all hosts; geo_blocked_country is a configurable list per tenant. Both land in the same detection pipeline as brute force and honeypot trips, so a new-country login followed by a canary read shows up as one chain.
FAQ
Is RIR data as accurate as MaxMind?
At country level: comparable, because MaxMind uses the same allocations as a base and adds heuristics on top. At city or ISP level: no, that's not in RIR data. For "new country for this user" you don't need city level.
May I just use the RIR files?
Yes. The delegated stats are public and intended for exactly this kind of use; there's no registration or licence. Redistribution is allowed too.
How often do allocations change?
Small changes arrive daily, but a block rarely moves between countries. Weekly refresh is plenty; monthly is acceptable.
Written by the monsys team — sysadmins who do this every day.
Done it by hand? Let monsys keep it running.
Everything in this guide runs in monsys as a continuous check, with history, alerts and audit evidence. 5 servers free, EU-hosted in Belgium, installed in 60 seconds.