Wie logde in vanuit welk land? SSH-logins geolokaliseren zonder MaxMind-licentie
De vijf RIR's (RIPE, ARIN, APNIC, AFRINIC, LACNIC) publiceren gratis welk IP-blok aan welk land is toegewezen. Met één download van 30 MB en veertig regels shell zie je van elke SSH-login het land, en alerteer je op "deze gebruiker logde nog nooit in vanuit dat land". Geen MaxMind, geen API-key, geen data naar buiten.
Inhoud
"Een geslaagde login vanaf een onbekend IP" is een matige alert: IP's veranderen, collega's werken vanuit een hotel, de VPN krijgt een nieuw adres. "Een geslaagde login van deze gebruiker vanuit een land waar hij nog nooit vandaan kwam" is een scherpe. Daarvoor heb je GeoIP nodig, en de reflex is MaxMind — met een licentie, een account, en sinds 2019 een EULA die je vraagt om hun data niet te herdistribueren. Het kan zonder. De vijf regionale internetregistries publiceren dagelijks welk adresblok aan welk land is toegewezen. Dat is geen stadsniveau, maar voor "welk land" is het de bron waar MaxMind zelf van vertrekt.
Stap 1: de RIR-bestanden ophalen
Elke RIR publiceert een delegated-<rir>-extended-latest-bestand: één regel per toegewezen blok, met landcode, starttadres en aantal adressen.
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-...
Formaat: registry|cc|type|start|value|date|status|opaque-id. Voor IPv4 is value het aantal adressen in het blok; voor IPv6 is het de prefixlengte. Alleen regels met status allocated of assigned zijn in gebruik.
Stap 2: één opzoektabel bouwen
Zet alle vijf bestanden om naar start_int end_int cc, gesorteerd. Dan is een lookup een enkele awk over 200 000 regels — tientallen milliseconden.
sudo tee /usr/local/sbin/geoip-build.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
# Bouwt /var/lib/geoip/v4.tsv (start end cc) uit de vijf RIR-bestanden.
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 als tekst, cc — we matchen op prefixlengte in de 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 regels
En de lookup zelf:
sudo tee /usr/local/bin/geoip >/dev/null <<'EOF'
#!/usr/bin/env bash
# geoip <ip> → landcode (ISO 3166-1 alpha-2) of "--"
ip=$1
if [[ $ip == *:* ]]; then
# IPv6: probeer prefixes van lang naar kort (grof maar voldoende: /48 .. /12)
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 # -- (privé, niet toegewezen)
Ververs de tabel wekelijks; de bestanden veranderen dagelijks maar toewijzingen verschuiven traag:
echo '20 4 * * 0 root /usr/local/sbin/geoip-fetch.sh && /usr/local/sbin/geoip-build.sh' | sudo tee /etc/cron.d/geoip
Stap 3: elke geslaagde login voorzien van een land
Op Ubuntu 24.04 en Debian 12 staan de logins in de journal. Eén regel per login met tijd, gebruiker, IP en land:
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 ← hier wil je een alert op
Per gebruiker het patroon van de laatste 90 dagen — dat is je 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
Stap 4: de alert — nieuw land voor deze gebruiker
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, opgebouwd uit de historie
ALLOW=/etc/geoip-allowed-countries # optioneel: landen die voor iedereen ok zijn (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" # één keer melden, daarna 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
# Baseline vullen met de laatste 90 dagen vóór je de cron aanzet — anders is dag één één grote 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
De keuze om na één melding het land in de baseline op te nemen, is bewust: de alert is "iets nieuws", niet "iets verbodens". Wil je een land hard blokkeren, dan is dat een firewall-regel (stap 5), geen alert.
Stap 5: landen blokkeren op de firewall (optioneel, met mate)
Als je organisatie nooit legitiem verkeer uit bepaalde landen verwacht, kun je ze op poort 22 weigeren. Met nftables en een set:
# Alle IPv4-blokken van twee landen als 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
Doe dit alleen voor SSH en alleen als je zeker bent dat je niemand buitensluit die er hoort — een collega op reis is ook "verkeer uit een ander land". Blokkeren op landniveau is geen beveiliging tegen een gerichte aanvaller (die huurt een VPS in Amsterdam), wel een goede ruisfilter.
Valkuilen
- VPN's en cloud-IP's. Een login via een bedrijfs-VPN krijgt het land van de VPN-uitgang; een login vanaf een AWS-instance het land van die regio. Dat is geen fout van de data, het is de werkelijkheid — en precies waarom "nieuw land voor deze gebruiker" beter werkt dan een vaste allowlist.
allocatedversusassigned. Beide zijn in gebruik;availableenreservedniet. Het script filtert erop.- Nauwkeurigheid. RIR-data zegt aan welk land een blok is toegewezen, niet waar het adres nu fysiek wordt gebruikt. Voor een multinational met één blok kan dat afwijken. Voor de vraag "is dit normaal voor deze gebruiker" is dat irrelevant: de baseline leert het patroon, ongeacht of het "klopt".
- IPv6. De Python-lookup is traag (leest het hele bestand per aanroep). Voor tientallen logins per dag is dat prima; voor duizenden schrijf je het om naar een gesorteerde prefix-tabel.
- Journal-retentie. Een 90-dagen-baseline vereist 90 dagen journal. Zet
MaxRetentionSec=90dayinjournald.confof bewaarlogins-30d.tsvcumulatief.
Wat je hiermee nog niet hebt
- Fleet-baseline. Gebruiker
jeroendie op server A altijd uit BE komt en op server B ineens uit RU — dat zie je alleen als beide servers dezelfde baseline delen. - Correlatie met wat erna gebeurt. Een nieuw-land-login gevolgd door een honeypot-read of een
sudobinnen twee minuten is een ander verhaal dan een nieuw-land-login die verder niets doet. - Onderhoud. Vijf downloads, een build-script en een baseline per host: het werkt, tot de dag dat een RIR zijn URL wijzigt en niemand het merkt.
Zo doet monsys het
De monsys-agent doet dezelfde GeoIP-lookup lokaal, met dezelfde RIR-data (de hub distribueert een wekelijks bijgewerkte tabel naar de agents — er gaat geen IP naar een externe dienst). De detectie new_country_login is per gebruiker en per tenant, dus de baseline geldt over alle hosts; geo_blocked_country is een configureerbare lijst per tenant. Beide landen in dezelfde detectiepijplijn als brute-force en honeypot-trips, zodat een nieuw-land-login gevolgd door een canary-read als één keten verschijnt.
FAQ
Is RIR-data even nauwkeurig als MaxMind?
Op landniveau: vergelijkbaar, omdat MaxMind dezelfde toewijzingen als basis gebruikt en er heuristieken bovenop legt. Op stads- of ISP-niveau: nee, dat zit niet in RIR-data. Voor "nieuw land voor deze gebruiker" heb je stadsniveau niet nodig.
Mag ik de RIR-bestanden zomaar gebruiken?
Ja. De delegated-stats zijn publiek en bedoeld voor exact dit soort gebruik; er is geen registratie of licentie. Herdistribueren mag ook.
Hoe vaak veranderen de toewijzingen?
Dagelijks komen er kleine wijzigingen bij, maar een blok verhuist zelden van land. Wekelijks verversen is ruim voldoende; maandelijks is acceptabel.
Geschreven door het monsys-team — sysadmins die dit dagelijks doen.
Zelf gedaan? Laat monsys het bijhouden.
Alles uit deze how-to draait in monsys als doorlopende check, met historie, alerts en audit-bewijs. 5 servers gratis, EU-gehost in België, geïnstalleerd in 60 seconden.