Adding GeoIP alerting to your SSH logins

If you ever look in your server logs, I’m sure you’ve seen the steady stream of failed SSH login attempts. Most of these come from the more lawless parts of the world like China and Russia. But what if they happen to guess your password? Or a password of one of your users?

If you’re familiar with how the internet works, then you’ll know that every internet reachable computer has its own unique number, the IP number. But you might not know that there is a governing body that regulates who gets to use which IP number ranges. This is the work of IANA. They keep track of which IP ranges go to which Internet Service Provider (ISP). So they also know which country a certain IP number belongs to. Finding out which country an IP number belongs to is called GeoIP.

These numbers are kept in lists that you can download from a number of places. One of these is IP2Location.com who offer a stripped down version of this list for personal use. I used to get the list from maxmind.com but they stopped offering an easy-to-get free version. In fact, there is a standard package geoip-bin with a tool made by maxmind, but the included database is from 2020 without any way to update it that I could find.

So we roll our own GeoIP lookup tool. Below is the script that will download the CSV data from IP2Locations and convert the data into an SQLite3 database. We do this so we can perform lookups later on. Note that I’ve implemented several tricks to speed up importing the data into the database. Have a look here for more info.

#!/bin/bash

# Check for the programs we need
for x in sqlite3 wget unzip; do
  [ "$(which $x)" = "" ] && echo "Please install $x: apt -y install $x" && exit
done

mkdir -p /opt/geoiplookup
cd /opt/geoiplookup
ln -s /opt/geoiplookup/geoiplookup /usr/local/bin

[ -e "IP2LOCATION-LITE-DB1.CSV.ZIP" ] || wget https://download.ip2location.com/lite/IP2LOCATION-LITE-DB1.CSV.ZIP     # Download GEOPIP CSV file if not present
[ -e "IP2LOCATION-LITE-DB1.CSV" ] || unzip IP2LOCATION-LITE-DB1.CSV.ZIP IP2LOCATION-LITE-DB1.CSV                     # Unpack ZIP file if needed


# Create the database
rm -f geoip.db

TOTAL=$(wc -l IP2LOCATION-LITE-DB1.CSV | cut -f 1 -d ' ')    # 249332

awk -v "TOTAL=$TOTAL" '
BEGIN{
FS="\"";
print "CREATE TABLE geoip(start INTEGER PRIMARY KEY NOT NULL, end INTEGER NOT NULL, country_code TEXT NOT NULL, country TEXT NOT NULL);"
print "PRAGMA synchronous = OFF;"
print "PRAGMA journal_mode = MEMORY;"
print "BEGIN TRANSACTION;"
}

{
gsub("\x27", "\x27\x27", $8);   # In SQLite, single quotes are escaped with another single quote
print "INSERT INTO geoip(start, end, country_code, country) VALUES(" $2 "," $4 ",\x27" $6 "\x27,\x27" $8 "\x27);"
lines++;
if(lines%1000 == 0)
  printf("\rProgress: %.1f", lines / TOTAL * 100) > "/dev/stderr";
}

END{
print "CREATE INDEX end_index ON geoip(end);"
print "END TRANSACTION;"
print "VACUUM;"
print "\rDatabase is complete" > "/dev/stderr";
}
' IP2LOCATION-LITE-DB1.CSV | sqlite3 geoip.db

rm -f IP2LOCATION-LITE-DB1.CSV

# EOF

Once this is done, we’ll have a geoip.db file with all the information we need. Now to build our lookup tool. I suggest putting it in /opt/geoiplookup/geoiplookup. The script above already put a softlink in /usr/local/bin for it.

#!/bin/bash

# Usage:    geoiplookup <IPv4 number>
#
# Result:   IP <tab> Start of range <tab> End of range <tab> Country code <tab> Country name <tab> Hostname (if any)

DB=/opt/geoiplookup/geoip.db

# Check for the programs we need
[ "$(which sqlite3)" = "" ] && echo "Please install sqlite3: apt -y install sqlite3" && exit
[ "$(which dig)" = "" ] && echo "Please install dig: apt -y install bind9-dnsutils" && exit

# Convert IP to decimal, look it up in the database and spit out the info
awk -v "IP=$1" '
BEGIN{
if(split(IP, arr, ".") != 4 || arr[1] > 255 || arr[2] > 255 || arr[3] > 255 || arr[4] > 255)
  {
  print "Error, \x27" IP "\x27 is not a valid IPv4 address" > "/dev/stderr";
  exit
  }

ip_decimal = lshift(arr[1], 24) + lshift(arr[2], 16) + lshift(arr[3], 8) + arr[4];


print "SELECT \"" IP "\",* FROM geoip WHERE start <= " ip_decimal " AND end >= " ip_decimal ";"
}

' | sqlite3 $DB | awk '
BEGIN{
FS="|";
OFS="\t";
}

function decimal_to_ip(dec,   arr)
{
arr[1]=rshift(dec, 24);
arr[2]=and(rshift(dec, 16), 0xFF);
arr[3]=and(rshift(dec, 8), 0xFF);
arr[4]=and(dec, 0xFF);
return arr[1] "." arr[2] "." arr[3] "." arr[4]
}

{
printf($1 OFS decimal_to_ip($2) OFS decimal_to_ip($3) OFS $4 OFS $5 OFS);
system("dig -x " $1 " +short | sed \x27s/\\.$//\x27");
}
'

# EOF

What happens here is that we convert the given IPv4 number into a decimal number. Then we do a quick lookup in the database to find out in what range this IP decimal number falls. Once found, we take the output from the database and make it more useful.

# geoiplookup 8.8.8.8
8.8.8.8    8.7.245.0    8.10.5.255    US    United States of America    dns.google

As you can see, it returns the IP, the range, the country of origin and a quick DNS lookup. You can call this from another script and get the part you need with a simple cut.

So now that we are able to associate a country with an IP number, how do we hook it into SSH’s login procedure? This is easier then you think because a lot of programs that deal with authentication already have a way to hook into it. This is done using PAM which helps programs from having to reinvent the wheel every time and allows system administrators to customize logins. For example, this is an easy way to add Single Sign-On (SSO) or two-factor authentication to an existing program (like SSH) without have to alter or even recompile it. Using this mechanism we can hook into the login procedure and run a script when a user is able to successfully log in.

I suggest saving this as /opt/geoiplookup/check_login.sh or fix the path if you put it elsewhere:

#!/bin/bash

# Open /etc/pam.d/sshd and add this line at the end
#
# session optional pam_exec.so quiet expose_authtok /opt/geoiplookup/check_login.sh
#
# Then restart sshd
#
# systemctl restart ssh

CC="$(/opt/geoiplookup/geoiplookup $PAM_RHOST | cut -f 4)"
HOST="$(/opt/geoiplookup/geoiplookup $PAM_RHOST | cut -f 6)"

if [ "$CC" \!= "NL" ]; then
  /usr/local/bin/telegram.sh "User $PAM_USER logged in via SSH from $PAM_RHOST ($CC - $HOST)"
fi

# EOF

Since I live in the Netherlands, a quick check is done here if a login happens from the Netherlands. If not, a message is send to my phone via Telegram (which is very bot friendly). You can of course use whatever notification method you wish. SMS notifications can be done via Twilio or you can roll your own phone notification using ntfy.

In any case, I hope this will help you keep your servers more secure.