feat: add GeoIP location lookup for session IPs using MaxMind MMDB#628
feat: add GeoIP location lookup for session IPs using MaxMind MMDB#628strausmann wants to merge 2 commits into
Conversation
…atchMon#623) Replaces the stub get_location_from_ip() with a real GeoIP lookup using MaxMind MMDB databases. Users provide MMDB files via volume mount and set the GEOIP_DB_PATH environment variable. Features: - Supports GeoLite2-City, GeoIP2-City, and GeoLite2-Country databases - Lazy loading with automatic hot-reload when DB file changes (mtime check) - Graceful fallback to "Unknown" when no database is configured - Extended private IP detection (Docker, Tailscale CGNAT, IPv6 ULA) - No external API calls — fully offline lookup Configuration: GEOIP_DB_PATH=/app/geoip (path to directory with .mmdb files)
There was a problem hiding this comment.
Pull request overview
Implements real GeoIP resolution for session IP addresses by using local MaxMind MMDB databases (provided via volume mount) instead of the previous stubbed "Unknown" location behavior.
Changes:
- Added MaxMind MMDB-based GeoIP lookup with lazy loading and mtime-based reload behavior.
- Extended private IP detection (incl. CGNAT and IPv6 ULA) and updated session listing to await async lookups via
Promise.all(). - Added the
maxminddependency to the backend workspace (and updated the root lockfile).
Reviewed changes
Copilot reviewed 2 out of 3 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
backend/src/routes/authRoutes.js |
Replaces stub IP→location logic with MaxMind MMDB lookup + updates /sessions to await async lookups |
backend/package.json |
Adds maxmind dependency for MMDB reading |
package-lock.json |
Locks maxmind and transitive deps (mmdb-lib, tiny-lru) for the workspace install |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| try { | ||
| const stat = fs.statSync(db.path); | ||
| const mtime = stat.mtimeMs; | ||
|
|
||
| if (!geoipReader || mtime !== geoipMtime) { | ||
| geoipReader = await maxmind.open(db.path); | ||
| geoipMtime = mtime; | ||
| geoipType = db.type; | ||
| logger.info( | ||
| `GeoIP database loaded: ${path.basename(db.path)} (${new Date(mtime).toISOString()})`, | ||
| ); | ||
| } | ||
| return geoipReader; | ||
| } catch (err) { | ||
| logger.warn(`GeoIP database error: ${err.message}`); | ||
| return null; | ||
| } |
There was a problem hiding this comment.
If maxmind.open() fails (corrupt/unreadable DB), geoipMtime is not updated and geoipReader is left as-is. This will cause every subsequent lookup to retry maxmind.open() and emit a warn log again, potentially spamming logs and adding latency until the file changes. Consider clearing the cached reader and recording the failing mtime/path (or a backoff timestamp) so failures are not retried on every request.
| // Strip IPv6 prefix for mapped IPv4 | ||
| const cleanIP = ip.replace(/^::ffff:/, ""); | ||
| return ( | ||
| cleanIP === "127.0.0.1" || | ||
| cleanIP.startsWith("10.") || | ||
| cleanIP.startsWith("192.168.") || | ||
| /^172\.(1[6-9]|2\d|3[01])\./.test(cleanIP) || | ||
| /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(cleanIP) || | ||
| cleanIP.startsWith("fd") // IPv6 ULA |
There was a problem hiding this comment.
isPrivateIP() checks IPv6 ULA via cleanIP.startsWith("fd"), but it doesn't normalize case. If an upstream component stores IPv6 addresses with uppercase hex (e.g., FD12::...), this will be missed and the code will attempt a GeoIP lookup for a private address. Consider lowercasing cleanIP before prefix checks (and keep the ::ffff: stripping on the original value).
| // Strip IPv6 prefix for mapped IPv4 | |
| const cleanIP = ip.replace(/^::ffff:/, ""); | |
| return ( | |
| cleanIP === "127.0.0.1" || | |
| cleanIP.startsWith("10.") || | |
| cleanIP.startsWith("192.168.") || | |
| /^172\.(1[6-9]|2\d|3[01])\./.test(cleanIP) || | |
| /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(cleanIP) || | |
| cleanIP.startsWith("fd") // IPv6 ULA | |
| // Strip IPv6 prefix for mapped IPv4 (keep this on the original value) | |
| const cleanIP = ip.replace(/^::ffff:/, ""); | |
| const normalizedIP = cleanIP.toLowerCase(); | |
| return ( | |
| normalizedIP === "127.0.0.1" || | |
| normalizedIP.startsWith("10.") || | |
| normalizedIP.startsWith("192.168.") || | |
| /^172\.(1[6-9]|2\d|3[01])\./.test(normalizedIP) || | |
| /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(normalizedIP) || | |
| normalizedIP.startsWith("fd") // IPv6 ULA (case-insensitive) |
| router.get("/sessions", authenticateToken, async (req, res) => { | ||
| try { | ||
| const sessions = await prisma.user_sessions.findMany({ | ||
| where: { | ||
| user_id: req.user.id, | ||
| is_revoked: false, | ||
| expires_at: { gt: new Date() }, | ||
| }, | ||
| select: { | ||
| id: true, | ||
| ip_address: true, | ||
| user_agent: true, | ||
| device_fingerprint: true, | ||
| last_activity: true, | ||
| created_at: true, | ||
| expires_at: true, | ||
| tfa_remember_me: true, | ||
| tfa_bypass_until: true, | ||
| login_count: true, | ||
| last_login_ip: true, | ||
| }, | ||
| orderBy: { last_activity: "desc" }, | ||
| }); | ||
|
|
||
| // Enhance sessions with device info | ||
| const enhanced_sessions = sessions.map((session) => { | ||
| const is_current_session = session.id === req.session_id; | ||
| const device_info = parse_user_agent(session.user_agent); | ||
|
|
||
| return { | ||
| ...session, | ||
| is_current_session, | ||
| device_info, | ||
| location_info: get_location_from_ip(session.ip_address), | ||
| }; | ||
| }); | ||
| // Enhance sessions with device info and location | ||
| const enhanced_sessions = await Promise.all( | ||
| sessions.map(async (session) => { | ||
| const is_current_session = session.id === req.session_id; | ||
| const device_info = parse_user_agent(session.user_agent); | ||
|
|
||
| return { | ||
| ...session, | ||
| is_current_session, | ||
| device_info, | ||
| location_info: await get_location_from_ip(session.ip_address), | ||
| }; | ||
| }), | ||
| ); |
There was a problem hiding this comment.
This change introduces new behavior in /sessions (async GeoIP lookups, private IP classification, and graceful fallback when the DB is missing/corrupt), but there are currently no route tests covering /api/v1/auth/sessions. Adding Jest tests that mock maxmind.open()/reader.get() would help prevent regressions (e.g., ensuring private IPs return Local and missing DB returns Unknown).
| if (!geoipReader || mtime !== geoipMtime) { | ||
| geoipReader = await maxmind.open(db.path); | ||
| geoipMtime = mtime; | ||
| geoipType = db.type; | ||
| logger.info( | ||
| `GeoIP database loaded: ${path.basename(db.path)} (${new Date(mtime).toISOString()})`, | ||
| ); | ||
| } |
There was a problem hiding this comment.
On the first lookup (or after a reload), /sessions triggers many concurrent get_location_from_ip() calls via Promise.all(). Since loadGeoIPReader() doesn't guard initialization, multiple calls can concurrently pass the !geoipReader check and all invoke maxmind.open(db.path), doing redundant work and potentially increasing latency. Consider serializing loads with a shared in-flight promise/mutex so only one open happens at a time.
| async function loadGeoIPReader() { | ||
| const db = findGeoIPDB(); | ||
| if (!db) return null; | ||
|
|
||
| try { | ||
| const stat = fs.statSync(db.path); | ||
| const mtime = stat.mtimeMs; | ||
|
|
||
| if (!geoipReader || mtime !== geoipMtime) { | ||
| geoipReader = await maxmind.open(db.path); | ||
| geoipMtime = mtime; | ||
| geoipType = db.type; | ||
| logger.info( | ||
| `GeoIP database loaded: ${path.basename(db.path)} (${new Date(mtime).toISOString()})`, | ||
| ); | ||
| } | ||
| return geoipReader; |
There was a problem hiding this comment.
loadGeoIPReader() only reloads the reader when the selected DB file's mtime changes. If the preferred DB file is removed/added and findGeoIPDB() starts returning a different path with the same mtimeMs value as the previous file, the code will keep using the old in-memory reader and geoipType, so hot-reload/file fallback can become inconsistent. Track the currently-loaded DB path (e.g., geoipPath) and reload when the path changes as well as when mtime changes.
| function findGeoIPDB() { | ||
| if (!GEOIP_DB_PATH) return null; | ||
| for (const db of GEOIP_DB_NAMES) { | ||
| const dbPath = path.join(GEOIP_DB_PATH, db.file); | ||
| if (fs.existsSync(dbPath)) { | ||
| return { path: dbPath, type: db.type }; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| async function loadGeoIPReader() { | ||
| const db = findGeoIPDB(); | ||
| if (!db) return null; | ||
|
|
||
| try { | ||
| const stat = fs.statSync(db.path); |
There was a problem hiding this comment.
findGeoIPDB()/loadGeoIPReader() use existsSync() and statSync() on the request path. When /sessions maps over many sessions this can result in multiple synchronous filesystem hits per request (and per lookup), blocking the event loop. Consider switching to async fs.promises APIs and/or caching the stat/mtime check (e.g., only re-stat at most once every N seconds, or load the reader once per request and reuse it across the sessions.map).
| function findGeoIPDB() { | |
| if (!GEOIP_DB_PATH) return null; | |
| for (const db of GEOIP_DB_NAMES) { | |
| const dbPath = path.join(GEOIP_DB_PATH, db.file); | |
| if (fs.existsSync(dbPath)) { | |
| return { path: dbPath, type: db.type }; | |
| } | |
| } | |
| return null; | |
| } | |
| async function loadGeoIPReader() { | |
| const db = findGeoIPDB(); | |
| if (!db) return null; | |
| try { | |
| const stat = fs.statSync(db.path); | |
| async function findGeoIPDB() { | |
| if (!GEOIP_DB_PATH) return null; | |
| for (const db of GEOIP_DB_NAMES) { | |
| const dbPath = path.join(GEOIP_DB_PATH, db.file); | |
| try { | |
| // Check that the file exists and is readable without blocking the event loop | |
| await fs.promises.access(dbPath, fs.constants.R_OK); | |
| return { path: dbPath, type: db.type }; | |
| } catch { | |
| // If access fails, try the next candidate | |
| } | |
| } | |
| return null; | |
| } | |
| async function loadGeoIPReader() { | |
| const db = await findGeoIPDB(); | |
| if (!db) return null; | |
| try { | |
| const stat = await fs.promises.stat(db.path); |
- Replace sync fs.existsSync/statSync with async fs.promises.stat and
fs.accessSync (non-blocking event loop)
- Add Promise-based lock to prevent parallel maxmind.open() calls
- Throttle mtime checks to once per 60s (avoids repeated FS calls)
- Keep existing reader on DB error instead of returning null (graceful)
- Fix IPv6 case-sensitivity: toLowerCase() before startsWith("fd")
|
Friendly ping — is there anything else needed from my side to get this reviewed? Happy to make adjustments if needed. |
Summary
Closes #623
Replaces the stub
get_location_from_ip()with a real GeoIP lookup using MaxMind MMDB databases. Users provide MMDB files via volume mount — no built-in download logic, no external API calls.Changes
backend/src/routes/authRoutes.js:100.64.0.0/10), IPv6 ULA (fd::/8)Promise.all()for async location lookupsbackend/package.json:maxminddependency (pure JS MMDB reader, no native code)Configuration
Searches for (in order):
GeoLite2-City.mmdb,GeoIP2-City.mmdb,GeoLite2-Country.mmdb.If not configured or no files found → graceful fallback to
"Unknown".Docker deployment
Hot-reload behavior
GEOIP_DB_PATHsetTest plan
GEOIP_DB_PATH→ sessions show "Unknown, Unknown" (no crash)10.x,192.168.x,100.64.x→ "Local, Local Network"::1,fd::→ "Local, Local Network"GeoLite2-Country.mmdb→ country resolved, city = "Unknown"