"""Coarse IP -> approximate-location lookup, for visitors who don't share browser geolocation. This is a *fallback suggestion*, never a position. Browser geolocation is metres-accurate; a city-level IP database is tens of kilometres accurate against a ~2 mile grid cell, so everything here is shaped so the caller cannot mistake one for the other: the payload is explicitly labelled approximate, and the UI that consumes it must offer a correction affordance (see frontend/static/geoip.js). Design rules this module enforces, in order of importance: 1. **The IP is never persisted.** ``lookup()`` takes a string, reads a memory -mapped database, and returns a dict. Nothing is written anywhere — no DB row, no log line, no metric dimension. The web route deliberately keeps the geoip category out of the access log too (see web/app.py), so there is never a joinable (IP, derived-city) record on disk. Callers must not log the argument. 2. **The lookup is local.** The database is a file on our own disk in MaxMind's open MMDB format. No third-party API is called, so the visitor's IP never leaves the server and no data-processor relationship is created. 3. **Off unless deliberately turned on.** ``THERMOGRAPH_GEOIP`` must be truthy *and* ``THERMOGRAPH_GEOIP_DB`` must point at a readable file *and* the ``maxminddb`` reader must be importable. Any one of those missing makes ``enabled()`` False and every ``lookup()`` return None — which the route turns into a 204 and the client treats exactly like today's no-fallback behaviour. There is no failure mode here that is worse than the status quo. 4. **Silence beats a wrong guess.** A reserved/private/CGNAT/loopback address, an unparseable one, a record with no city (a country centroid would put a VPN user in the geographic middle of a country they aren't in), or a record whose stated accuracy radius is worse than ``THERMOGRAPH_GEOIP_MAX_RADIUS_KM`` all return None rather than something plausible-looking. Database choice is a deployment concern, not a code one: both DB-IP's *IP to City Lite* (CC BY 4.0, no account) and MaxMind's *GeoLite2 City* (GeoLite EULA, account + licence key) ship the same MMDB format with the same record shape, so either file works unchanged. ``attribution()`` reads the file's own metadata so the credit line the UI renders follows whichever file is installed. See GEOIP-APPROX-LOCATION.md for the comparison and the recommendation. """ import ipaddress import os import threading from data import grid FLAG_ENV = "THERMOGRAPH_GEOIP" DB_ENV = "THERMOGRAPH_GEOIP_DB" RADIUS_ENV = "THERMOGRAPH_GEOIP_MAX_RADIUS_KM" ATTRIB_ENV = "THERMOGRAPH_GEOIP_ATTRIBUTION" # City-level databases claim ~50 km typical accuracy. Anything the file itself # admits is worse than this is a region/country centroid dressed up as a city, # which is exactly the "wrong part of the map" guess we'd rather not make. DEFAULT_MAX_RADIUS_KM = 100.0 # Extra reserved ranges checked explicitly rather than leaning on # ipaddress.is_global alone: the exact membership of is_private/is_global has # moved between CPython releases (100.64/10 in particular), and "which Python # is this container on" must not decide whether we try to geolocate a # carrier-NAT address. _EXTRA_RESERVED = ( ipaddress.ip_network("100.64.0.0/10"), # RFC 6598 carrier-grade NAT ipaddress.ip_network("192.0.0.0/24"), # IETF protocol assignments ipaddress.ip_network("198.18.0.0/15"), # benchmarking ipaddress.ip_network("fc00::/7"), # IPv6 unique-local ipaddress.ip_network("2001:db8::/32"), # documentation ) _lock = threading.Lock() _reader = None _reader_key: "tuple | None" = None # (path, mtime, size) the open reader came from def _truthy(v: "str | None") -> bool: return (v or "").strip().lower() in ("1", "true", "yes", "on") def db_path() -> str: return os.environ.get(DB_ENV, "").strip() def max_radius_km() -> float: try: return float(os.environ.get(RADIUS_ENV, "") or DEFAULT_MAX_RADIUS_KM) except ValueError: return DEFAULT_MAX_RADIUS_KM def enabled() -> bool: """True only when the flag is set AND a database file is actually present. Read on every call (not cached) so flipping the flag or dropping the file in doesn't need a restart, and so a test can toggle it with monkeypatch.setenv.""" if not _truthy(os.environ.get(FLAG_ENV)): return False path = db_path() return bool(path) and os.path.isfile(path) def _open(): """The memory-mapped reader for the configured database, or None. Re-opens when the file's (mtime, size) changes so the monthly refresh job can swap the file in atomically and be picked up without a deploy. mmap means the 100-200 MB file costs page cache, not process RSS, and is shared across uvicorn workers on the same host. """ global _reader, _reader_key path = db_path() if not path: return None try: st = os.stat(path) except OSError: return None key = (path, st.st_mtime_ns, st.st_size) with _lock: if _reader is not None and _reader_key == key: return _reader try: import maxminddb except ImportError: return None try: new = maxminddb.open_database(path, maxminddb.MODE_MMAP) except Exception: # noqa: BLE001 - a corrupt/half-written file must not 500 return None old = _reader _reader, _reader_key = new, key if old is not None: try: old.close() except Exception: # noqa: BLE001 pass return new def reset() -> None: """Drop the cached reader. For tests, and for anything that rewrites the database in place rather than swapping it atomically.""" global _reader, _reader_key with _lock: r, _reader, _reader_key = _reader, None, None if r is not None: try: r.close() except Exception: # noqa: BLE001 pass def parse_ip(raw: "str | None") -> "ipaddress._BaseAddress | None": """The routable address in ``raw``, or None. Handles the shapes an X-Forwarded-For hop actually arrives in — a bare address, ``[v6]:port``, ``v4:port``, a ``%zone`` suffix — and rejects everything that can't identify a location on the public internet: private LAN ranges (every LAN-dev and internal-proxy request), loopback, link-local, multicast, carrier-grade NAT, IPv6 unique-local, and anything unparseable. An IPv4-mapped IPv6 address is unwrapped so ``::ffff:8.8.8.8`` is treated as the IPv4 address it is. """ s = (raw or "").strip() if not s: return None if s.startswith("["): # [2001:db8::1]:443 s = s[1:].partition("]")[0] elif s.count(":") == 1: # 8.8.8.8:443 (a lone colon is never v6) s = s.partition(":")[0] s = s.partition("%")[0] # fe80::1%eth0 try: addr = ipaddress.ip_address(s) except ValueError: return None mapped = getattr(addr, "ipv4_mapped", None) if mapped is not None: addr = mapped if not addr.is_global: return None if addr.is_private or addr.is_loopback or addr.is_link_local: return None if addr.is_multicast or addr.is_reserved or addr.is_unspecified: return None for net in _EXTRA_RESERVED: if addr.version == net.version and addr in net: return None return addr def _name(node) -> str: """The English display name out of an MMDB names map. Both DB-IP Lite and GeoLite2 store ``{"names": {"en": ..., "de": ...}}``; DB-IP Lite is English-only in some builds, so fall back to whatever single name exists rather than dropping an otherwise-good record.""" if not isinstance(node, dict): return "" names = node.get("names") if not isinstance(names, dict): return "" for key in ("en", "en-US"): v = names.get(key) if isinstance(v, str) and v.strip(): return v.strip() for v in names.values(): if isinstance(v, str) and v.strip(): return v.strip() return "" def suggestion_from_record(record, *, max_radius: "float | None" = None) -> "dict | None": """Turn one raw MMDB record into the wire payload, or None if it is too coarse to be worth showing. Split out from ``lookup()`` so every rejection rule is testable without a database file.""" if not isinstance(record, dict): return None loc = record.get("location") if not isinstance(loc, dict): return None lat, lon = loc.get("latitude"), loc.get("longitude") if not isinstance(lat, (int, float)) or not isinstance(lon, (int, float)): return None if not (-90.0 <= lat <= 90.0) or not (-180.0 <= lon <= 180.0): return None # No city name means a country (or continent) centroid. Showing one would # put every VPN user in the geographic middle of a country they may not be # in, which is worse than showing nothing: it looks like a real answer. city = _name(record.get("city")) if not city: return None radius = loc.get("accuracy_radius") limit = max_radius_km() if max_radius is None else max_radius if isinstance(radius, (int, float)) and radius > limit: return None subs = record.get("subdivisions") region = _name(subs[0]) if isinstance(subs, list) and subs else "" country_node = record.get("country") or record.get("registered_country") or {} country = _name(country_node) code = country_node.get("iso_code") if isinstance(country_node, dict) else None # Snap to the app's own ~2 mile grid. This drops the database's fake # precision (city centroids are published to 4+ decimal places, which is # metres, for a value good to tens of kilometres) and returns exactly the # cell the weather would be fetched for anyway — so the coordinate carries # no more information than the city name already did. cell = grid.snap(float(lat), float(lon)) # "Springfield" alone is ambiguous; "Springfield, Illinois" is not. Region # first (more useful locally), country when there is no region. label = f"{city}, {region}" if region else (f"{city}, {country}" if country else city) return { "approximate": True, # never omit: the client keys its copy off this "source": "ip", "label": label, "city": city, "region": region or None, "country": country or None, "country_code": code if isinstance(code, str) else None, "lat": cell["center_lat"], "lon": cell["center_lon"], "accuracy_radius_km": radius if isinstance(radius, (int, float)) else None, "attribution": attribution(), } def lookup(ip: "str | None") -> "dict | None": """An approximate-location suggestion for ``ip``, or None. None covers every degraded case — feature off, database missing, private or malformed address, no matching record, record too coarse — so the caller has exactly one branch to write and it is always "behave as if this feature did not exist". Never raises; never logs or stores ``ip``. """ if not enabled(): return None addr = parse_ip(ip) if addr is None: return None reader = _open() if reader is None: return None try: record = reader.get(str(addr)) except Exception: # noqa: BLE001 - a bad record must degrade, not 500 return None if record is None: return None return suggestion_from_record(record) # --- attribution ----------------------------------------------------------- # Both candidate databases are free *with attribution*, and each wants its own # wording, so the credit is derived from the installed file's own metadata # instead of hardcoded — swapping databases stays a config change. An explicit # THERMOGRAPH_GEOIP_ATTRIBUTION ("text|url") overrides, for a file whose # database_type we don't recognise. _ATTRIBUTIONS = { "dbip": ("IP Geolocation by DB-IP", "https://db-ip.com"), "geolite2": ("This product includes GeoLite data created by MaxMind", "https://www.maxmind.com"), } def attribution() -> "dict | None": """``{"text", "url"}`` for the installed database, or None if it can't be determined. Rendered next to any result the visitor sees — both licences require credit on pages that display their data.""" override = os.environ.get(ATTRIB_ENV, "").strip() if override: text, _, url = override.partition("|") return {"text": text.strip(), "url": url.strip() or None} reader = _open() if reader is None: return None try: kind = (reader.metadata().database_type or "").lower() except Exception: # noqa: BLE001 return None if "dbip" in kind or "db-ip" in kind: text, url = _ATTRIBUTIONS["dbip"] elif "geolite" in kind: text, url = _ATTRIBUTIONS["geolite2"] else: return None return {"text": text, "url": url}