All checks were successful
PR build (required check) / changes (pull_request) Successful in 6s
secrets-guard / encrypted (pull_request) Successful in 5s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Successful in 1m8s
PR build (required check) / build-backend (pull_request) Successful in 1m36s
PR build (required check) / gate (pull_request) Successful in 2s
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.
330 lines
13 KiB
Python
330 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}
|