Qui s'est connecté depuis quel pays ? Géolocaliser les connexions SSH sans licence MaxMind
Les cinq RIR (RIPE, ARIN, APNIC, AFRINIC, LACNIC) publient gratuitement quel bloc d'IP est attribué à quel pays. Avec un téléchargement de 30 Mo et quarante lignes de shell, vous voyez le pays de chaque connexion SSH et vous alertez sur « cet utilisateur ne s'est jamais connecté depuis ce pays ». Sans MaxMind, sans clé API, sans données qui sortent.
Sommaire
- Étape 1 : récupérer les fichiers RIR
- Étape 2 : construire une table de recherche
- Étape 3 : ajouter un pays à chaque connexion réussie
- Étape 4 : l'alerte — nouveau pays pour cet utilisateur
- Étape 5 : bloquer des pays au pare-feu (optionnel, avec mesure)
- Pièges
- Ce qu'il vous manque encore
- Comment monsys fait
- FAQ
« Une connexion réussie depuis une IP inconnue » est une alerte médiocre : les IP changent, les collègues travaillent depuis un hôtel, le VPN reçoit une nouvelle adresse. « Une connexion réussie de cet utilisateur depuis un pays d'où il n'est jamais venu » est une alerte nette. Il faut pour cela du GeoIP, et le réflexe est MaxMind — avec une licence, un compte, et depuis 2019 un contrat qui vous demande de ne pas redistribuer leurs données. On peut s'en passer. Les cinq registres internet régionaux publient chaque jour quel bloc d'adresses est attribué à quel pays. Ce n'est pas au niveau de la ville, mais pour « quel pays », c'est la source dont MaxMind lui-même part.
Étape 1 : récupérer les fichiers RIR
Chaque RIR publie un fichier delegated-<rir>-extended-latest : une ligne par bloc attribué, avec code pays, adresse de départ et nombre d'adresses.
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. Pour IPv4, value est le nombre d'adresses du bloc ; pour IPv6 c'est la longueur du préfixe. Seules les lignes avec le statut allocated ou assigned sont en usage.
Étape 2 : construire une table de recherche
Convertissez les cinq fichiers en start_int end_int cc, triés. Une recherche devient alors un seul awk sur 200 000 lignes — quelques dizaines de millisecondes.
sudo tee /usr/local/sbin/geoip-build.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
# Construit /var/lib/geoip/v4.tsv (start end cc) à partir des cinq fichiers RIR.
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 : préfixe en texte, cc — on fait correspondre sur la longueur de préfixe dans la recherche
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 lignes
Et la recherche elle-même :
sudo tee /usr/local/bin/geoip >/dev/null <<'EOF'
#!/usr/bin/env bash
# geoip <ip> → code pays (ISO 3166-1 alpha-2) ou "--"
ip=$1
if [[ $ip == *:* ]]; then
# IPv6 : le préfixe correspondant le plus long gagne
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ée, non attribuée)
Rafraîchissez la table chaque semaine ; les fichiers changent tous les jours mais les attributions bougent lentement :
echo '20 4 * * 0 root /usr/local/sbin/geoip-fetch.sh && /usr/local/sbin/geoip-build.sh' | sudo tee /etc/cron.d/geoip
Étape 3 : ajouter un pays à chaque connexion réussie
Sur Ubuntu 24.04 et Debian 12, les connexions sont dans le journal. Une ligne par connexion avec heure, utilisateur, IP et pays :
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 ← c'est là-dessus que vous voulez une alerte
Par utilisateur, le schéma des 90 derniers jours — c'est votre 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
Étape 4 : l'alerte — nouveau pays pour cet utilisateur
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, construit à partir de l'historique
ALLOW=/etc/geoip-allowed-countries # optionnel : pays acceptables pour tout le monde (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" # signaler une fois, puis 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
# Remplir la baseline avec les 90 derniers jours AVANT d'activer le cron — sinon le premier jour est une seule grande alerte
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
Le choix d'ajouter le pays à la baseline après un seul signalement est délibéré : l'alerte signifie « quelque chose de nouveau », pas « quelque chose d'interdit ». Pour bloquer durement un pays, c'est une règle de pare-feu (étape 5), pas une alerte.
Étape 5 : bloquer des pays au pare-feu (optionnel, avec mesure)
Si votre organisation n'attend jamais de trafic légitime de certains pays, vous pouvez les refuser sur le port 22. Avec nftables et un set :
# Tous les blocs IPv4 de deux pays comme set nft
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
Faites-le uniquement pour SSH et uniquement si vous êtes sûr de n'exclure personne qui devrait entrer — un collègue en voyage, c'est aussi « du trafic d'un autre pays ». Le blocage par pays n'est pas une défense contre un attaquant ciblé (il loue un VPS à Amsterdam), mais c'est un bon filtre à bruit.
Pièges
- VPN et IP cloud. Une connexion via un VPN d'entreprise reçoit le pays de la sortie VPN ; une connexion depuis une instance AWS, le pays de cette région. Ce n'est pas une erreur des données, c'est la réalité — et exactement pourquoi « nouveau pays pour cet utilisateur » fonctionne mieux qu'une liste blanche fixe.
allocatedversusassigned. Les deux sont en usage ;availableetreservednon. Le script filtre là-dessus.- Précision. Les données RIR disent à quel pays un bloc est attribué, pas où l'adresse est physiquement utilisée. Pour une multinationale avec un seul bloc, cela peut différer. Pour la question « est-ce normal pour cet utilisateur », c'est sans importance : la baseline apprend le schéma, qu'il soit « exact » ou non.
- IPv6. La recherche Python est lente (elle lit tout le fichier à chaque appel). Pour des dizaines de connexions par jour, c'est très bien ; pour des milliers, réécrivez-la en table de préfixes triée.
- Rétention du journal. Une baseline de 90 jours nécessite 90 jours de journal. Mettez
MaxRetentionSec=90daydansjournald.confou conservezlogins-30d.tsvde façon cumulative.
Ce qu'il vous manque encore
- Une baseline de flotte. L'utilisateur
jeroenqui vient toujours de BE sur le serveur A et soudain de RU sur le serveur B — vous ne le voyez que si les deux serveurs partagent la même baseline. - La corrélation avec ce qui suit. Une connexion depuis un nouveau pays suivie d'une lecture de honeypot ou d'un
sudodans les deux minutes est une autre histoire qu'une connexion depuis un nouveau pays qui ne fait rien d'autre. - La maintenance. Cinq téléchargements, un script de construction et une baseline par hôte : ça marche, jusqu'au jour où un RIR change son URL et personne ne le remarque.
Comment monsys fait
L'agent monsys fait la même recherche GeoIP localement, avec les mêmes données RIR (le hub distribue aux agents une table mise à jour chaque semaine — aucune IP ne part vers un service externe). La détection new_country_login est par utilisateur et par tenant, donc la baseline vaut pour tous les hôtes ; geo_blocked_country est une liste configurable par tenant. Les deux atterrissent dans le même pipeline de détection que le brute force et les honeypots, de sorte qu'une connexion depuis un nouveau pays suivie d'une lecture de canari apparaît comme une seule chaîne.
FAQ
Les données RIR sont-elles aussi précises que MaxMind ?
Au niveau du pays : comparables, car MaxMind utilise les mêmes attributions comme base et y ajoute des heuristiques. Au niveau de la ville ou du FAI : non, ce n'est pas dans les données RIR. Pour « nouveau pays pour cet utilisateur », vous n'avez pas besoin du niveau ville.
Puis-je simplement utiliser les fichiers RIR ?
Oui. Les delegated stats sont publics et destinés précisément à ce type d'usage ; il n'y a ni inscription ni licence. La redistribution est aussi autorisée.
À quelle fréquence les attributions changent-elles ?
De petits changements arrivent chaque jour, mais un bloc change rarement de pays. Un rafraîchissement hebdomadaire suffit largement ; mensuel est acceptable.
Rédigé par l'équipe monsys — des sysadmins qui font cela tous les jours.
Fait à la main ? Laissez monsys s'en charger en continu.
Tout ce que contient ce guide tourne dans monsys comme contrôle permanent, avec historique, alertes et preuves d'audit. 5 serveurs gratuits, hébergés en Belgique, installés en 60 secondes.