331 lines
13 KiB
Python
331 lines
13 KiB
Python
|
|
"""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}
|