Security & detectiegevorderd4 min lezen

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
  1. Stap 1: de RIR-bestanden ophalen
  2. Stap 2: één opzoektabel bouwen
  3. Stap 3: elke geslaagde login voorzien van een land
  4. Stap 4: de alert — nieuw land voor deze gebruiker
  5. Stap 5: landen blokkeren op de firewall (optioneel, met mate)
  6. Valkuilen
  7. Wat je hiermee nog niet hebt
  8. Zo doet monsys het
  9. FAQ

"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.
  • allocated versus assigned. Beide zijn in gebruik; available en reserved niet. 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=90day in journald.conf of bewaar logins-30d.tsv cumulatief.

Wat je hiermee nog niet hebt

  • Fleet-baseline. Gebruiker jeroen die 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 sudo binnen 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.