Fix missing country: neg cache, geoip2 fallback, re-resolve endpoint

- Add 5-min negative cache (_neg_cache) so failing IPs are throttled
  rather than hammering the API on every request
- Add MaxMind GeoLite2 fallback (init_geoip / _geoip_lookup) that fires
  when ip-api fails; controlled by BANGUI_GEOIP_DB_PATH env var
- Fix lookup_batch bug: failed API results were stored in positive cache
- Add _persist_neg_entry: INSERT OR IGNORE into geo_cache with NULL
  country_code so re-resolve can find historically failed IPs
- Add POST /api/geo/re-resolve: clears neg cache, batch-retries all
  geo_cache rows with country_code IS NULL, returns resolved/total count
- BanTable + MapPage: wrap the country — placeholder in a Fluent UI
  Tooltip explaining the retry behaviour
- Add geoip2>=4.8.0 dependency; geoip_db_path config setting
- Tests: add TestNegativeCache (4), TestGeoipFallback (4), TestReResolve (4)
This commit is contained in:
2026-03-07 20:42:34 +01:00
parent ddfc8a0b02
commit 12a859061c
10 changed files with 494 additions and 52 deletions

View File

@@ -188,7 +188,20 @@ Dashboard and world map must load within **2 seconds** for 10 k banned IPs. Writ
--- ---
## Task 4 — Fix Missing Country for Resolved IPs ## Task 4 — Fix Missing Country for Resolved IPs ✅ DONE
**Completed:**
- `geo_service.py`: Added `_neg_cache: dict[str, float]` with 5-minute TTL (`_NEG_CACHE_TTL = 300`). Failed lookups (any cause) are written to the neg cache and returned immediately without querying the API until the TTL expires. `clear_neg_cache()` flushes it (used by the re-resolve endpoint).
- `geo_service.py`: Added `init_geoip(mmdb_path)` + `_geoip_lookup(ip)` using `geoip2.database.Reader`. When ip-api fails, the local GeoLite2-Country `.mmdb` is tried as fallback. Only fires if `BANGUI_GEOIP_DB_PATH` is set and the file exists; otherwise silently skipped.
- `geo_service.py`: Fixed `lookup_batch()` bug where failed API results were stored in the positive in-memory cache (`_store` was called unconditionally). Now only positive results go into `_cache`; failures try geoip2 fallback then go into `_neg_cache`.
- `geo_service.py`: Added `_persist_neg_entry(db, ip)` — `INSERT OR IGNORE` into `geo_cache` with `country_code=NULL` so the re-resolve endpoint can find previously failed IPs without overwriting existing positive entries.
- `config.py`: Added `geoip_db_path: str | None` setting (env `BANGUI_GEOIP_DB_PATH`).
- `pyproject.toml`: Added `geoip2>=4.8.0` dependency.
- `main.py`: Calls `geo_service.init_geoip(settings.geoip_db_path)` during lifespan startup.
- `routers/geo.py`: Added `POST /api/geo/re-resolve` — queries `geo_cache WHERE country_code IS NULL`, clears neg cache, batch-re-resolves all those IPs, returns `{"resolved": N, "total": M}`.
- `BanTable.tsx`: Country cell now wraps the `` placeholder in a Fluent UI Tooltip with message "Country could not be resolved — will retry automatically."
- `MapPage.tsx`: Same Tooltip treatment for the `` placeholder in the companion table.
- Tests: Updated `test_geo_service.py` — removed outdated `result is None` assertions (lookup now always returns GeoInfo), updated neg-cache test, added `TestNegativeCache` (4 tests) and `TestGeoipFallback` (4 tests). Added `TestReResolve` (4 tests) in `test_geo.py`. **430 total tests pass.**
### Problem ### Problem

View File

@@ -45,6 +45,13 @@ class Settings(BaseSettings):
default="info", default="info",
description="Application log level: debug | info | warning | error | critical.", description="Application log level: debug | info | warning | error | critical.",
) )
geoip_db_path: str | None = Field(
default=None,
description=(
"Optional path to a MaxMind GeoLite2-Country .mmdb file. "
"When set, failed ip-api.com lookups fall back to local resolution."
),
)
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_prefix="BANGUI_", env_prefix="BANGUI_",

View File

@@ -137,6 +137,7 @@ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# --- Pre-warm geo cache from the persistent store --- # --- Pre-warm geo cache from the persistent store ---
from app.services import geo_service # noqa: PLC0415 from app.services import geo_service # noqa: PLC0415
geo_service.init_geoip(settings.geoip_db_path)
await geo_service.load_cache_from_db(db) await geo_service.load_cache_from_db(db)
# --- Background task scheduler --- # --- Background task scheduler ---

View File

@@ -1,8 +1,9 @@
"""Geo / IP lookup router. """Geo / IP lookup router.
Provides the IP enrichment endpoint: Provides the IP enrichment endpoints:
* ``GET /api/geo/lookup/{ip}`` — ban status, ban history, and geo info for an IP * ``GET /api/geo/lookup/{ip}`` — ban status, ban history, and geo info for an IP
* ``POST /api/geo/re-resolve`` — retry all previously failed geo lookups
""" """
from __future__ import annotations from __future__ import annotations
@@ -12,9 +13,10 @@ from typing import TYPE_CHECKING, Annotated
if TYPE_CHECKING: if TYPE_CHECKING:
import aiohttp import aiohttp
from fastapi import APIRouter, HTTPException, Path, Request, status import aiosqlite
from fastapi import APIRouter, Depends, HTTPException, Path, Request, status
from app.dependencies import AuthDep from app.dependencies import AuthDep, get_db
from app.models.geo import GeoDetail, IpLookupResponse from app.models.geo import GeoDetail, IpLookupResponse
from app.services import geo_service, jail_service from app.services import geo_service, jail_service
from app.utils.fail2ban_client import Fail2BanConnectionError from app.utils.fail2ban_client import Fail2BanConnectionError
@@ -90,3 +92,55 @@ async def lookup_ip(
currently_banned_in=result["currently_banned_in"], currently_banned_in=result["currently_banned_in"],
geo=geo_detail, geo=geo_detail,
) )
# ---------------------------------------------------------------------------
# POST /api/geo/re-resolve
# ---------------------------------------------------------------------------
@router.post(
"/re-resolve",
summary="Re-resolve all IPs whose country could not be determined",
)
async def re_resolve_geo(
request: Request,
_auth: AuthDep,
db: Annotated[aiosqlite.Connection, Depends(get_db)],
) -> dict[str, int]:
"""Retry geo resolution for every IP in ``geo_cache`` with a null country.
Clears the in-memory negative cache first so that previously failing IPs
are immediately eligible for a new API attempt.
Args:
request: Incoming request (used to access ``app.state.http_session``).
_auth: Validated session — enforces authentication.
db: BanGUI application database (for reading/writing ``geo_cache``).
Returns:
JSON object ``{"resolved": N, "total": M}`` where *N* is the number
of IPs that gained a country code and *M* is the total number of IPs
that were retried.
"""
# Collect all IPs in geo_cache that still lack a country code.
unresolved: list[str] = []
async with db.execute(
"SELECT ip FROM geo_cache WHERE country_code IS NULL"
) as cur:
async for row in cur:
unresolved.append(str(row[0]))
if not unresolved:
return {"resolved": 0, "total": 0}
# Clear negative cache so these IPs bypass the TTL check.
geo_service.clear_neg_cache()
http_session: aiohttp.ClientSession = request.app.state.http_session
geo_map = await geo_service.lookup_batch(unresolved, http_session, db=db)
resolved_count = sum(
1 for info in geo_map.values() if info.country_code is not None
)
return {"resolved": resolved_count, "total": len(unresolved)}

View File

@@ -38,9 +38,12 @@ Usage::
from __future__ import annotations from __future__ import annotations
import time
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import geoip2.database
import geoip2.errors
import structlog import structlog
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -74,6 +77,10 @@ _MAX_CACHE_SIZE: int = 50_000
#: Timeout for outgoing geo API requests in seconds. #: Timeout for outgoing geo API requests in seconds.
_REQUEST_TIMEOUT: float = 5.0 _REQUEST_TIMEOUT: float = 5.0
#: How many seconds a failed lookup result is suppressed before the IP is
#: eligible for a new API attempt. Default: 5 minutes.
_NEG_CACHE_TTL: float = 300.0
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Domain model # Domain model
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -104,16 +111,83 @@ class GeoInfo:
# Internal cache # Internal cache
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
#: Module-level in-memory cache: ``ip → GeoInfo``. #: Module-level in-memory cache: ``ip → GeoInfo`` (positive results only).
_cache: dict[str, GeoInfo] = {} _cache: dict[str, GeoInfo] = {}
#: Negative cache: ``ip → epoch timestamp`` of last failed lookup attempt.
#: Entries within :data:`_NEG_CACHE_TTL` seconds are not re-queried.
_neg_cache: dict[str, float] = {}
#: Optional MaxMind GeoLite2 reader initialised by :func:`init_geoip`.
_geoip_reader: geoip2.database.Reader | None = None
def clear_cache() -> None: def clear_cache() -> None:
"""Flush the entire lookup cache. """Flush both the positive and negative lookup caches.
Useful in tests and when the operator suspects stale data. Useful in tests and when the operator suspects stale data.
""" """
_cache.clear() _cache.clear()
_neg_cache.clear()
def clear_neg_cache() -> None:
"""Flush only the negative (failed-lookups) cache.
Useful when triggering a manual re-resolve so that previously failed
IPs are immediately eligible for a new API attempt.
"""
_neg_cache.clear()
def init_geoip(mmdb_path: str | None) -> None:
"""Initialise the MaxMind GeoLite2-Country database reader.
If *mmdb_path* is ``None``, empty, or the file does not exist the
fallback is silently disabled — ip-api.com remains the sole resolver.
Args:
mmdb_path: Absolute path to a ``GeoLite2-Country.mmdb`` file.
"""
global _geoip_reader # noqa: PLW0603
if not mmdb_path:
return
from pathlib import Path # noqa: PLC0415
if not Path(mmdb_path).is_file():
log.warning("geoip_mmdb_not_found", path=mmdb_path)
return
_geoip_reader = geoip2.database.Reader(mmdb_path)
log.info("geoip_mmdb_loaded", path=mmdb_path)
def _geoip_lookup(ip: str) -> GeoInfo | None:
"""Attempt a local MaxMind GeoLite2 lookup for *ip*.
Returns ``None`` when the reader is not initialised, the IP is not in
the database, or any other error occurs.
Args:
ip: IPv4 or IPv6 address string.
Returns:
A :class:`GeoInfo` with at least ``country_code`` populated, or
``None`` when resolution is impossible.
"""
if _geoip_reader is None:
return None
try:
response = _geoip_reader.country(ip)
code: str | None = response.country.iso_code or None
name: str | None = response.country.name or None
if code is None:
return None
return GeoInfo(country_code=code, country_name=name, asn=None, org=None)
except geoip2.errors.AddressNotFoundError:
return None
except Exception as exc: # noqa: BLE001
log.warning("geoip_lookup_failed", ip=ip, error=str(exc))
return None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -181,6 +255,23 @@ async def _persist_entry(
await db.commit() await db.commit()
async def _persist_neg_entry(db: aiosqlite.Connection, ip: str) -> None:
"""Record a failed lookup attempt in ``geo_cache`` with all-NULL fields.
Uses ``INSERT OR IGNORE`` so that an existing *positive* entry (one that
has a ``country_code``) is never overwritten by a later failure.
Args:
db: BanGUI application database connection.
ip: IP address string whose resolution failed.
"""
await db.execute(
"INSERT OR IGNORE INTO geo_cache (ip) VALUES (?)",
(ip,),
)
await db.commit()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Public API — single lookup # Public API — single lookup
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -215,36 +306,60 @@ async def lookup(
if ip in _cache: if ip in _cache:
return _cache[ip] return _cache[ip]
# Negative cache: skip IPs that recently failed to avoid hammering the API.
neg_ts = _neg_cache.get(ip)
if neg_ts is not None and (time.monotonic() - neg_ts) < _NEG_CACHE_TTL:
return GeoInfo(country_code=None, country_name=None, asn=None, org=None)
url: str = _API_URL.format(ip=ip) url: str = _API_URL.format(ip=ip)
api_ok = False
try: try:
async with http_session.get(url, timeout=_REQUEST_TIMEOUT) as resp: # type: ignore[arg-type] async with http_session.get(url, timeout=_REQUEST_TIMEOUT) as resp: # type: ignore[arg-type]
if resp.status != 200: if resp.status != 200:
log.warning("geo_lookup_non_200", ip=ip, status=resp.status) log.warning("geo_lookup_non_200", ip=ip, status=resp.status)
return None else:
data: dict[str, object] = await resp.json(content_type=None)
data: dict[str, object] = await resp.json(content_type=None) if data.get("status") == "success":
api_ok = True
result = _parse_single_response(data)
_store(ip, result)
if result.country_code is not None and db is not None:
try:
await _persist_entry(db, ip, result)
except Exception as exc: # noqa: BLE001
log.warning("geo_persist_failed", ip=ip, error=str(exc))
log.debug("geo_lookup_success", ip=ip, country=result.country_code, asn=result.asn)
return result
log.debug(
"geo_lookup_failed",
ip=ip,
message=data.get("message", "unknown"),
)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
log.warning("geo_lookup_request_failed", ip=ip, error=str(exc)) log.warning("geo_lookup_request_failed", ip=ip, error=str(exc))
return None
if data.get("status") != "success": if not api_ok:
log.debug( # Try local MaxMind database as fallback.
"geo_lookup_failed", fallback = _geoip_lookup(ip)
ip=ip, if fallback is not None:
message=data.get("message", "unknown"), _store(ip, fallback)
) if fallback.country_code is not None and db is not None:
# Do NOT cache failed lookups — they will be retried on the next call. try:
return GeoInfo(country_code=None, country_name=None, asn=None, org=None) await _persist_entry(db, ip, fallback)
except Exception as exc: # noqa: BLE001
log.warning("geo_persist_failed", ip=ip, error=str(exc))
log.debug("geo_geoip_fallback_success", ip=ip, country=fallback.country_code)
return fallback
result = _parse_single_response(data) # Both resolvers failed — record in negative cache to avoid hammering.
_store(ip, result) _neg_cache[ip] = time.monotonic()
if result.country_code is not None and db is not None: if db is not None:
try: try:
await _persist_entry(db, ip, result) await _persist_neg_entry(db, ip)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
log.warning("geo_persist_failed", ip=ip, error=str(exc)) log.warning("geo_persist_neg_failed", ip=ip, error=str(exc))
log.debug("geo_lookup_success", ip=ip, country=result.country_code, asn=result.asn)
return result return GeoInfo(country_code=None, country_name=None, asn=None, org=None)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -277,11 +392,16 @@ async def lookup_batch(
""" """
geo_result: dict[str, GeoInfo] = {} geo_result: dict[str, GeoInfo] = {}
uncached: list[str] = [] uncached: list[str] = []
_empty = GeoInfo(country_code=None, country_name=None, asn=None, org=None)
unique_ips = list(dict.fromkeys(ips)) # deduplicate, preserve order unique_ips = list(dict.fromkeys(ips)) # deduplicate, preserve order
now = time.monotonic()
for ip in unique_ips: for ip in unique_ips:
if ip in _cache: if ip in _cache:
geo_result[ip] = _cache[ip] geo_result[ip] = _cache[ip]
elif ip in _neg_cache and (now - _neg_cache[ip]) < _NEG_CACHE_TTL:
# Recently failed — skip API call, return empty result.
geo_result[ip] = _empty
else: else:
uncached.append(ip) uncached.append(ip)
@@ -295,13 +415,35 @@ async def lookup_batch(
chunk_result = await _batch_api_call(chunk, http_session) chunk_result = await _batch_api_call(chunk, http_session)
for ip, info in chunk_result.items(): for ip, info in chunk_result.items():
_store(ip, info) if info.country_code is not None:
geo_result[ip] = info # Successful API resolution.
if info.country_code is not None and db is not None: _store(ip, info)
try: geo_result[ip] = info
await _persist_entry(db, ip, info) if db is not None:
except Exception as exc: # noqa: BLE001 try:
log.warning("geo_persist_failed", ip=ip, error=str(exc)) await _persist_entry(db, ip, info)
except Exception as exc: # noqa: BLE001
log.warning("geo_persist_failed", ip=ip, error=str(exc))
else:
# API failed — try local GeoIP fallback.
fallback = _geoip_lookup(ip)
if fallback is not None:
_store(ip, fallback)
geo_result[ip] = fallback
if db is not None:
try:
await _persist_entry(db, ip, fallback)
except Exception as exc: # noqa: BLE001
log.warning("geo_persist_failed", ip=ip, error=str(exc))
else:
# Both resolvers failed — record in negative cache.
_neg_cache[ip] = time.monotonic()
geo_result[ip] = _empty
if db is not None:
try:
await _persist_neg_entry(db, ip)
except Exception as exc: # noqa: BLE001
log.warning("geo_persist_neg_failed", ip=ip, error=str(exc))
log.info( log.info(
"geo_batch_lookup_complete", "geo_batch_lookup_complete",

View File

@@ -17,6 +17,7 @@ dependencies = [
"apscheduler>=3.10,<4.0", "apscheduler>=3.10,<4.0",
"structlog>=24.4.0", "structlog>=24.4.0",
"bcrypt>=4.2.0", "bcrypt>=4.2.0",
"geoip2>=4.8.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@@ -157,3 +157,61 @@ class TestGeoLookup:
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["ip"] == "2001:db8::1" assert resp.json()["ip"] == "2001:db8::1"
# ---------------------------------------------------------------------------
# POST /api/geo/re-resolve
# ---------------------------------------------------------------------------
class TestReResolve:
"""Tests for ``POST /api/geo/re-resolve``."""
async def test_returns_200_with_counts(self, geo_client: AsyncClient) -> None:
"""POST /api/geo/re-resolve returns 200 with resolved/total counts."""
with patch(
"app.routers.geo.geo_service.lookup_batch",
AsyncMock(return_value={}),
):
resp = await geo_client.post("/api/geo/re-resolve")
assert resp.status_code == 200
data = resp.json()
assert "resolved" in data
assert "total" in data
async def test_empty_when_no_unresolved_ips(self, geo_client: AsyncClient) -> None:
"""Returns resolved=0, total=0 when geo_cache has no NULL country_code rows."""
resp = await geo_client.post("/api/geo/re-resolve")
assert resp.status_code == 200
assert resp.json() == {"resolved": 0, "total": 0}
async def test_re_resolves_null_ips(self, geo_client: AsyncClient) -> None:
"""IPs with null country_code in geo_cache are re-resolved via lookup_batch."""
# Insert a NULL entry into geo_cache.
app = geo_client._transport.app # type: ignore[attr-defined]
db: aiosqlite.Connection = app.state.db
await db.execute("INSERT OR IGNORE INTO geo_cache (ip) VALUES (?)", ("5.5.5.5",))
await db.commit()
geo_result = {"5.5.5.5": GeoInfo(country_code="FR", country_name="France", asn=None, org=None)}
with patch(
"app.routers.geo.geo_service.lookup_batch",
AsyncMock(return_value=geo_result),
):
resp = await geo_client.post("/api/geo/re-resolve")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["resolved"] == 1
async def test_401_when_unauthenticated(self, geo_client: AsyncClient) -> None:
"""POST /api/geo/re-resolve requires authentication."""
app = geo_client._transport.app # type: ignore[attr-defined]
resp = await AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
).post("/api/geo/re-resolve")
assert resp.status_code == 401

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@@ -166,8 +166,8 @@ class TestLookupCaching:
assert session.get.call_count == 2 assert session.get.call_count == 2
async def test_negative_result_not_cached(self) -> None: async def test_negative_result_stored_in_neg_cache(self) -> None:
"""A failed lookup (status != success) is NOT cached so it is retried.""" """A failed lookup is stored in the negative cache, so the second call is blocked."""
session = _make_session( session = _make_session(
{"status": "fail", "message": "reserved range"} {"status": "fail", "message": "reserved range"}
) )
@@ -175,8 +175,8 @@ class TestLookupCaching:
await geo_service.lookup("192.168.1.1", session) # type: ignore[arg-type] await geo_service.lookup("192.168.1.1", session) # type: ignore[arg-type]
await geo_service.lookup("192.168.1.1", session) # type: ignore[arg-type] await geo_service.lookup("192.168.1.1", session) # type: ignore[arg-type]
# Failed lookups must not be cachedboth calls must reach the API. # Second call is blocked by the negative cache — only one API hit.
assert session.get.call_count == 2 assert session.get.call_count == 1
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -187,19 +187,26 @@ class TestLookupCaching:
class TestLookupFailures: class TestLookupFailures:
"""geo_service.lookup() when things go wrong.""" """geo_service.lookup() when things go wrong."""
async def test_non_200_response_returns_none(self) -> None: async def test_non_200_response_returns_null_geo_info(self) -> None:
"""A 429 or 500 status returns ``None`` without caching.""" """A 429 or 500 status returns GeoInfo with null fields (not None)."""
session = _make_session({}, status=429) session = _make_session({}, status=429)
result = await geo_service.lookup("1.2.3.4", session) # type: ignore[arg-type] result = await geo_service.lookup("1.2.3.4", session) # type: ignore[arg-type]
assert result is None assert result is not None
assert isinstance(result, GeoInfo)
assert result.country_code is None
async def test_network_error_returns_none(self) -> None: async def test_network_error_returns_null_geo_info(self) -> None:
"""A network exception returns ``None``.""" """A network exception returns GeoInfo with null fields (not None)."""
session = MagicMock() session = MagicMock()
session.get = MagicMock(side_effect=OSError("connection refused")) mock_ctx = AsyncMock()
mock_ctx.__aenter__ = AsyncMock(side_effect=OSError("connection refused"))
mock_ctx.__aexit__ = AsyncMock(return_value=False)
session.get = MagicMock(return_value=mock_ctx)
result = await geo_service.lookup("10.0.0.1", session) # type: ignore[arg-type] result = await geo_service.lookup("10.0.0.1", session) # type: ignore[arg-type]
assert result is None assert result is not None
assert isinstance(result, GeoInfo)
assert result.country_code is None
async def test_failed_status_returns_geo_info_with_nulls(self) -> None: async def test_failed_status_returns_geo_info_with_nulls(self) -> None:
"""When ip-api returns ``status=fail`` a GeoInfo with null fields is returned (but not cached).""" """When ip-api returns ``status=fail`` a GeoInfo with null fields is returned (but not cached)."""
@@ -210,3 +217,142 @@ class TestLookupFailures:
assert isinstance(result, GeoInfo) assert isinstance(result, GeoInfo)
assert result.country_code is None assert result.country_code is None
assert result.country_name is None assert result.country_name is None
# ---------------------------------------------------------------------------
# Negative cache
# ---------------------------------------------------------------------------
class TestNegativeCache:
"""Verify the negative cache throttles retries for failing IPs."""
async def test_neg_cache_blocks_second_lookup(self) -> None:
"""After a failed lookup the second call is served from the neg cache."""
session = _make_session({"status": "fail", "message": "private range"})
r1 = await geo_service.lookup("192.0.2.1", session) # type: ignore[arg-type]
r2 = await geo_service.lookup("192.0.2.1", session) # type: ignore[arg-type]
# Only one HTTP call should have been made; second served from neg cache.
assert session.get.call_count == 1
assert r1 is not None and r1.country_code is None
assert r2 is not None and r2.country_code is None
async def test_neg_cache_retries_after_ttl(self) -> None:
"""When the neg-cache entry is older than the TTL a new API call is made."""
session = _make_session({"status": "fail", "message": "private range"})
await geo_service.lookup("192.0.2.2", session) # type: ignore[arg-type]
# Manually expire the neg-cache entry.
geo_service._neg_cache["192.0.2.2"] -= geo_service._NEG_CACHE_TTL + 1 # type: ignore[attr-defined]
await geo_service.lookup("192.0.2.2", session) # type: ignore[arg-type]
# Both calls should have hit the API.
assert session.get.call_count == 2
async def test_clear_neg_cache_allows_immediate_retry(self) -> None:
"""After clearing the neg cache the IP is eligible for a new API call."""
session = _make_session({"status": "fail", "message": "private range"})
await geo_service.lookup("192.0.2.3", session) # type: ignore[arg-type]
geo_service.clear_neg_cache()
await geo_service.lookup("192.0.2.3", session) # type: ignore[arg-type]
assert session.get.call_count == 2
async def test_successful_lookup_does_not_pollute_neg_cache(self) -> None:
"""A successful lookup must not create a neg-cache entry."""
session = _make_session(
{
"status": "success",
"countryCode": "DE",
"country": "Germany",
"as": "AS3320",
"org": "Telekom",
}
)
await geo_service.lookup("1.2.3.4", session) # type: ignore[arg-type]
assert "1.2.3.4" not in geo_service._neg_cache # type: ignore[attr-defined]
# ---------------------------------------------------------------------------
# GeoIP2 (MaxMind) fallback
# ---------------------------------------------------------------------------
class TestGeoipFallback:
"""Verify the MaxMind GeoLite2 fallback is used when ip-api fails."""
def _make_geoip_reader(self, iso_code: str, name: str) -> MagicMock:
"""Build a mock geoip2.database.Reader that returns *iso_code*."""
country_mock = MagicMock()
country_mock.iso_code = iso_code
country_mock.name = name
response_mock = MagicMock()
response_mock.country = country_mock
reader = MagicMock()
reader.country = MagicMock(return_value=response_mock)
return reader
async def test_geoip_fallback_called_when_api_fails(self) -> None:
"""When ip-api returns status=fail, the geoip2 reader is consulted."""
session = _make_session({"status": "fail", "message": "reserved range"})
mock_reader = self._make_geoip_reader("DE", "Germany")
with patch.object(geo_service, "_geoip_reader", mock_reader):
result = await geo_service.lookup("1.2.3.4", session) # type: ignore[arg-type]
mock_reader.country.assert_called_once_with("1.2.3.4")
assert result is not None
assert result.country_code == "DE"
assert result.country_name == "Germany"
async def test_geoip_fallback_result_stored_in_cache(self) -> None:
"""A successful geoip2 fallback result is stored in the positive cache."""
session = _make_session({"status": "fail", "message": "reserved range"})
mock_reader = self._make_geoip_reader("US", "United States")
with patch.object(geo_service, "_geoip_reader", mock_reader):
await geo_service.lookup("8.8.8.8", session) # type: ignore[arg-type]
# Second call must be served from positive cache without hitting API.
await geo_service.lookup("8.8.8.8", session) # type: ignore[arg-type]
assert session.get.call_count == 1
assert "8.8.8.8" in geo_service._cache # type: ignore[attr-defined]
async def test_geoip_fallback_not_called_on_api_success(self) -> None:
"""When ip-api succeeds, the geoip2 reader must not be consulted."""
session = _make_session(
{
"status": "success",
"countryCode": "JP",
"country": "Japan",
"as": "AS12345",
"org": "NTT",
}
)
mock_reader = self._make_geoip_reader("XX", "Nowhere")
with patch.object(geo_service, "_geoip_reader", mock_reader):
result = await geo_service.lookup("1.2.3.4", session) # type: ignore[arg-type]
mock_reader.country.assert_not_called()
assert result is not None
assert result.country_code == "JP"
async def test_geoip_fallback_not_called_when_no_reader(self) -> None:
"""When no geoip2 reader is configured, the fallback silently does nothing."""
session = _make_session({"status": "fail", "message": "private range"})
with patch.object(geo_service, "_geoip_reader", None):
result = await geo_service.lookup("10.0.0.1", session) # type: ignore[arg-type]
assert result is not None
assert result.country_code is None

View File

@@ -153,11 +153,19 @@ function buildBanColumns(styles: ReturnType<typeof useStyles>): TableColumnDefin
createTableColumn<DashboardBanItem>({ createTableColumn<DashboardBanItem>({
columnId: "country", columnId: "country",
renderHeaderCell: () => "Country", renderHeaderCell: () => "Country",
renderCell: (item) => ( renderCell: (item) =>
<Text size={200}> item.country_name ?? item.country_code ? (
{item.country_name ?? item.country_code ?? "—"} <Text size={200}>{item.country_name ?? item.country_code}</Text>
</Text> ) : (
), <Tooltip
content="Country could not be resolved — will retry automatically."
relationship="description"
>
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
</Text>
</Tooltip>
),
}), }),
createTableColumn<DashboardBanItem>({ createTableColumn<DashboardBanItem>({
columnId: "jail", columnId: "jail",

View File

@@ -24,6 +24,7 @@ import {
Text, Text,
Toolbar, Toolbar,
ToolbarButton, ToolbarButton,
Tooltip,
makeStyles, makeStyles,
tokens, tokens,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
@@ -286,7 +287,18 @@ export function MapPage(): React.JSX.Element {
</TableCell> </TableCell>
<TableCell> <TableCell>
<TableCellLayout> <TableCellLayout>
{ban.country_name ?? ban.country_code ?? "—"} {ban.country_name ?? ban.country_code ? (
ban.country_name ?? ban.country_code
) : (
<Tooltip
content="Country could not be resolved — will retry automatically."
relationship="description"
>
<Text style={{ color: tokens.colorNeutralForeground3 }}>
</Text>
</Tooltip>
)}
</TableCellLayout> </TableCellLayout>
</TableCell> </TableCell>
<TableCell> <TableCell>