Skip to content

Commit a010637

Browse files
author
GitHub Copilot
committed
Add GeoIP lookup for request originators
- Add geoip2 dependency for IP geolocation - Create new geoip module with MaxMind GeoLite2 support - Add originator_requests Prometheus metric to track requester locations - Track originator location separately from requested weather locations - Add tests for GeoIP functionality - Gracefully handle missing GeoIP database (optional feature) The new metric 'fingr_originator_requests_total' tracks where requests come from geographically (based on IP), while 'fingr_location_requests_total' tracks which locations people are requesting weather for. This allows visualization of both on a map with different colors.
1 parent 8882317 commit a010637

File tree

6 files changed

+1722
-0
lines changed

6 files changed

+1722
-0
lines changed

fingr/geoip.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""GeoIP lookup for request originator locations."""
2+
3+
from typing import Optional
4+
5+
from .logging import get_logger
6+
7+
logger = get_logger(__name__)
8+
9+
# Global GeoIP reader (initialized in server startup)
10+
geoip_reader: Optional[object] = None
11+
12+
13+
def init_geoip() -> None:
14+
"""Initialize GeoIP reader with MaxMind database."""
15+
global geoip_reader
16+
try:
17+
import geoip2.database
18+
19+
# Try to load GeoLite2-City database from common locations
20+
database_paths = [
21+
"/usr/share/GeoIP/GeoLite2-City.mmdb",
22+
"/var/lib/GeoIP/GeoLite2-City.mmdb",
23+
"./GeoLite2-City.mmdb",
24+
"/etc/fingr/GeoLite2-City.mmdb",
25+
]
26+
27+
for db_path in database_paths:
28+
try:
29+
geoip_reader = geoip2.database.Reader(db_path)
30+
logger.info("GeoIP database loaded", path=db_path)
31+
return
32+
except FileNotFoundError:
33+
continue
34+
except Exception as e:
35+
logger.warning("Failed to load GeoIP database", path=db_path, error=str(e))
36+
37+
logger.warning(
38+
"GeoIP database not found. Originator location tracking disabled. "
39+
"Install GeoLite2-City.mmdb to enable."
40+
)
41+
except ImportError:
42+
logger.warning("geoip2 library not available. Originator location tracking disabled.")
43+
44+
45+
def lookup_ip_location(ip_address: str) -> tuple[Optional[float], Optional[float], str]:
46+
"""
47+
Lookup geographic location for an IP address.
48+
49+
Returns:
50+
Tuple of (latitude, longitude, location_name)
51+
Returns (None, None, "unknown") if lookup fails or database not available.
52+
"""
53+
# Skip private/local IPs first (regardless of database availability)
54+
if ip_address in ("127.0.0.1", "::1", "localhost") or ip_address.startswith("192.168."):
55+
return None, None, "local"
56+
57+
if geoip_reader is None:
58+
return None, None, "unknown"
59+
60+
try:
61+
response = geoip_reader.city(ip_address) # type: ignore[attr-defined]
62+
lat = response.location.latitude
63+
lon = response.location.longitude
64+
65+
# Build location name
66+
parts = []
67+
if response.city.name:
68+
parts.append(response.city.name)
69+
if response.country.name:
70+
parts.append(response.country.name)
71+
72+
location_name = ", ".join(parts) if parts else "unknown"
73+
74+
logger.debug(
75+
"GeoIP lookup successful", ip=ip_address, lat=lat, lon=lon, location=location_name
76+
)
77+
return lat, lon, location_name
78+
79+
except Exception as e:
80+
logger.debug("GeoIP lookup failed", ip=ip_address, error=str(e))
81+
return None, None, "unknown"

fingr/metrics.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@
5353
["latitude_bucket", "longitude_bucket", "address"],
5454
)
5555

56+
originator_requests = Counter(
57+
"fingr_originator_requests_total",
58+
"Requests by originator geographic location",
59+
["latitude_bucket", "longitude_bucket", "location"],
60+
)
61+
5662
# Processing time breakdown
5763
location_resolution_duration = Histogram(
5864
"fingr_location_resolution_duration_seconds",
@@ -101,3 +107,16 @@ def record_location_request(lat: float, lon: float, address: str) -> None:
101107
longitude_bucket=lon_bucket,
102108
address=address_short,
103109
).inc()
110+
111+
112+
def record_originator_request(lat: float, lon: float, location: str) -> None:
113+
"""Record an originator request with bucketed coordinates."""
114+
lat_bucket = bucket_coordinate(lat)
115+
lon_bucket = bucket_coordinate(lon)
116+
# Truncate location to avoid cardinality explosion
117+
location_short = location[:50] if len(location) > 50 else location
118+
originator_requests.labels(
119+
latitude_bucket=lat_bucket,
120+
longitude_bucket=lon_bucket,
121+
location=location_short,
122+
).inc()

fingr/server.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@
1212

1313
from .config import load_deny_list, load_motd_list, load_user_agent, random_message
1414
from .formatting import format_meteogram, format_oneliner
15+
from .geoip import init_geoip, lookup_ip_location
1516
from .location import RedisClient, get_timezone, resolve_location
1617
from .logging import get_logger
1718
from .metrics import (
1819
formatting_duration,
1920
record_location_request,
21+
record_originator_request,
2022
request_duration,
2123
requests_total,
2224
track_time,
@@ -100,6 +102,18 @@ async def handle_request(reader: asyncio.StreamReader, writer: asyncio.StreamWri
100102

101103
logger.debug("Request received", ip=addr[0], input=user_input)
102104

105+
# Lookup originator location from IP
106+
orig_lat, orig_lon, orig_location = lookup_ip_location(addr[0])
107+
if orig_lat is not None and orig_lon is not None:
108+
record_originator_request(orig_lat, orig_lon, orig_location)
109+
logger.debug(
110+
"Originator location tracked",
111+
ip=addr[0],
112+
location=orig_location,
113+
lat=orig_lat,
114+
lon=orig_lon,
115+
)
116+
103117
# Deny list
104118
if addr[0] in denylist:
105119
logger.info("Request from blacklisted IP", ip=addr[0], input=user_input)
@@ -225,6 +239,9 @@ async def start_server(args: argparse.Namespace) -> None:
225239
user_agent = load_user_agent()
226240
geolocator = Nominatim(user_agent=user_agent, timeout=3)
227241

242+
# Initialize GeoIP
243+
init_geoip()
244+
228245
# Connect to Redis with retry
229246
logger.info("Connecting to Redis", host=args.redis_host, port=args.redis_port)
230247
r = redis.Redis(host=args.redis_host, port=args.redis_port)

fingr_test.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from fingr.config import random_message
1313
from fingr.formatting import sun_up
14+
from fingr.geoip import lookup_ip_location
1415
from fingr.location import get_timezone, resolve_location
1516
from fingr.utils import clean_input, wind_direction
1617

@@ -79,6 +80,28 @@ def test_sun_up(self):
7980
test = sun_up(latitude=59, longitude=11, date=dt)
8081
self.assertFalse(test)
8182

83+
def test_geoip_lookup_local(self):
84+
"""Test GeoIP lookup for local addresses"""
85+
lat, lon, location = lookup_ip_location("127.0.0.1")
86+
self.assertIsNone(lat)
87+
self.assertIsNone(lon)
88+
self.assertEqual(location, "local")
89+
90+
def test_geoip_lookup_private(self):
91+
"""Test GeoIP lookup for private addresses"""
92+
lat, lon, location = lookup_ip_location("192.168.1.1")
93+
self.assertIsNone(lat)
94+
self.assertIsNone(lon)
95+
self.assertEqual(location, "local")
96+
97+
def test_geoip_lookup_unknown(self):
98+
"""Test GeoIP lookup without database (returns unknown)"""
99+
# When no database is loaded, should return unknown
100+
lat, lon, location = lookup_ip_location("8.8.8.8")
101+
# Will be None/unknown if database not available
102+
# If database is available, will return actual location
103+
self.assertTrue(location in ["unknown", "local"] or isinstance(location, str))
104+
82105

83106
if __name__ == "__main__":
84107
unittest.main()

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ dependencies = [
3333
"pytz==2024.2",
3434
"structlog>=24.1.0",
3535
"prometheus-client~=0.21.0",
36+
"geoip2~=4.8.0",
3637
]
3738

3839
[project.optional-dependencies]

0 commit comments

Comments
 (0)