Don't extract GeoIP .mmdb.gz on filesystem, only in RAM.

This commit is contained in:
2026-09-04 18:30:55 +00:00
parent 13fecd2118
commit b30d909a23
2 changed files with 23 additions and 22 deletions
+3 -2
View File
@@ -75,8 +75,9 @@ available, is stored as `host`; local/reserved/multicast addresses are
skipped. If a DB-IP MMDB file (`dbip-*.mmdb` or `dbip-*.mmdb.gz`) is present skipped. If a DB-IP MMDB file (`dbip-*.mmdb` or `dbip-*.mmdb.gz`) is present
in the working directory, it is loaded at startup and used to look up in the working directory, it is loaded at startup and used to look up
`country`/`city`. These lookups run in background tasks after the event is `country`/`city`. These lookups run in background tasks after the event is
stored, so WebSocket message handling is never delayed. The decompressed stored, so WebSocket message handling is never delayed. Only the downloaded
`dbip-*.mmdb` file is kept in the working directory and ignored by git. The `.mmdb.gz` is kept on disk (in the working directory, ignored by git); it is
decompressed into RAM when opened. The
CLI flag `--dbip` (`uv run pagerite --dbip`) downloads the latest CLI flag `--dbip` (`uv run pagerite --dbip`) downloads the latest
`dbip-city-lite-YYYY-MM.mmdb.gz` from DB-IP at startup (in the app lifespan, `dbip-city-lite-YYYY-MM.mmdb.gz` from DB-IP at startup (in the app lifespan,
before the MMDB is opened), skipping the download when the local database is before the MMDB is opened), skipping the download when the local database is
+20 -20
View File
@@ -3,18 +3,19 @@
The visitor-activity WebSocket (``/_ws``, public) and the admin analytics The visitor-activity WebSocket (``/_ws``, public) and the admin analytics
stream (``/_api/ws/analytics``) plus the ``/_a`` viewer page. Client IPs are stream (``/_api/ws/analytics``) plus the ``/_a`` viewer page. Client IPs are
enriched in background tasks with reverse DNS (cached PTR lookups) and the enriched in background tasks with reverse DNS (cached PTR lookups) and the
DB-IP city MMDB (``GeoIP``, decompressed and opened once at startup); DB-IP city MMDB (``GeoIP``, decompressed into RAM and opened once at
startup);
external referrers get their favicon fetched and stored content-hashed. external referrers get their favicon fetched and stored content-hashed.
Snapshot broadcasts to connected admin sockets are debounced. Snapshot broadcasts to connected admin sockets are debounced.
""" """
import asyncio import asyncio
import gzip import gzip
import io
import ipaddress import ipaddress
import logging import logging
import os import os
import re import re
import shutil
import socket import socket
from datetime import date from datetime import date
from functools import lru_cache from functools import lru_cache
@@ -103,15 +104,19 @@ def _download_dbip() -> None:
def _geoip_db_path() -> Path | None: def _geoip_db_path() -> Path | None:
"""Find a DB-IP MMDB in the working directory, preferring an already-decompressed """Find a DB-IP MMDB in the working directory: the ``.mmdb.gz`` download
``.mmdb`` over the matching ``.mmdb.gz``. Returns None if none is present. is canonical (decompressed into RAM at open); a plain ``.mmdb`` left over
from older versions is still usable, and removed once the matching ``.gz``
is present so it does not linger on disk. Returns None if none is present.
""" """
gz = sorted(_DBIP_DIR.glob("dbip-*.mmdb.gz"))
if gz:
for stale in _DBIP_DIR.glob("dbip-*.mmdb"):
stale.unlink()
return gz[0]
mmdb = sorted(_DBIP_DIR.glob("dbip-*.mmdb")) mmdb = sorted(_DBIP_DIR.glob("dbip-*.mmdb"))
if mmdb: if mmdb:
return mmdb[0] return mmdb[0]
gz = sorted(_DBIP_DIR.glob("dbip-*.mmdb.gz"))
if gz:
return gz[0]
return None return None
@@ -124,28 +129,23 @@ class GeoIP:
def __init__(self) -> None: def __init__(self) -> None:
self._reader: object | None = None self._reader: object | None = None
def _decompress(self, source: Path, target: Path) -> None:
if target.exists():
return
tmp = target.with_suffix(target.suffix + ".tmp")
with gzip.open(source, "rb") as src, open(tmp, "wb") as dst:
shutil.copyfileobj(src, dst)
os.replace(tmp, target)
def _load(self) -> None: def _load(self) -> None:
if self._reader is not None: if self._reader is not None:
return return
source = _geoip_db_path() source = _geoip_db_path()
if source is None: if source is None:
return return
if source.suffix == ".gz":
target = source.with_suffix("")
self._decompress(source, target)
source = target
try: try:
import maxminddb import maxminddb
self._reader = maxminddb.open_database(str(source)) if source.suffix == ".gz":
# Only the .gz is kept on disk; the database is decompressed
# into RAM (MODE_FD makes the pure-Python Reader .read() the
# buffer — never mmap — and bypasses the C extension).
buf = io.BytesIO(gzip.decompress(source.read_bytes()))
self._reader = maxminddb.open_database(buf, maxminddb.MODE_FD)
else:
self._reader = maxminddb.open_database(str(source))
except Exception: except Exception:
pass pass