thermograph/backend/data/geoip.py

331 lines
13 KiB
Python
Raw Normal View History

Approximate-location fallback from the client IP (feature-flagged off) A visitor who declines browser geolocation currently has no location at all — the copy sends them to the map picker. This adds an opt-in fallback that suggests a coarse city from the request's own IP, presented as a guess with a one-tap correction, so the dead end has a way out. backend/data/geoip.py does the lookup against a local MMDB file (DB-IP IP to City Lite or GeoLite2 City — same format, either works, attribution derived from the file's metadata). Off unless THERMOGRAPH_GEOIP is truthy AND the database exists AND maxminddb imports; every degraded case — private/reserved/ CGNAT/IPv6-ULA addresses, unparseable input, no record, a country-centroid record with no city, a record wider than the accuracy limit, a corrupt file — returns None, which the route answers as 204 and the client treats exactly as today. The IP is read in memory and dropped. GET /api/v2/geoip runs no RunAudit, records no metric dimension, and is excluded from the access log (its own "geoip" traffic category) so no stored, joinable (IP, location) pair is ever written. The response is no-store/Vary:* and takes no parameters. Client-side rather than server-rendered on purpose: the homepage's HTML and weak ETag must stay byte-identical for every visitor, or any cache added in front of it can serve one visitor's city to another. The suggestion is fetched only after a declined/unavailable prompt, and only when nothing is remembered. A guess is never persisted — no localStorage write, no URL hash — and the hero and results headings say "roughly near"/"near … approximate" rather than letting the reverse-geocoded cell name a neighbourhood the lookup never knew. The /privacy page's "never looks up your location from your IP address" paragraph now follows the same flag out of the same env file, so the published statement and the behaviour flip together. Database lifecycle is a host-side systemd timer (infra/deploy/geoip-refresh.*) that verifies a download opens and answers before swapping it in atomically, bind-mounted read-only; geoip.py re-opens on mtime change, so a refresh needs no restart. GEOIP-APPROX-LOCATION.md carries the database comparison, the SSR-vs-client argument, the privacy analysis, and the open decisions.
2026-07-23 23:22:37 +00:00
"""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}