Skip to content

feat: add GeoIP location lookup for session IPs using MaxMind MMDB#628

Open
strausmann wants to merge 2 commits into
PatchMon:mainfrom
strausmann:feat/623-geoip-location-lookup
Open

feat: add GeoIP location lookup for session IPs using MaxMind MMDB#628
strausmann wants to merge 2 commits into
PatchMon:mainfrom
strausmann:feat/623-geoip-location-lookup

Conversation

@strausmann

Copy link
Copy Markdown

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:

  • Replaced stub function with MaxMind-based async GeoIP lookup
  • Lazy DB loading with automatic hot-reload on file change (mtime check)
  • Extended private IP detection: Docker networks, Tailscale CGNAT (100.64.0.0/10), IPv6 ULA (fd::/8)
  • Session listing now uses Promise.all() for async location lookups

backend/package.json:

  • Added maxmind dependency (pure JS MMDB reader, no native code)

Configuration

# Optional: path to directory containing MaxMind MMDB files
GEOIP_DB_PATH=/app/geoip

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

services:
  backend:
    environment:
      GEOIP_DB_PATH: /app/geoip
    volumes:
      - geoip_data:/app/geoip:ro

  # Optional: auto-update sidecar
  geoipupdate:
    image: ghcr.io/maxmind/geoipupdate:latest
    environment:
      GEOIPUPDATE_ACCOUNT_ID: "123456"
      GEOIPUPDATE_LICENSE_KEY: "xxxxxx"
      GEOIPUPDATE_EDITION_IDS: "GeoLite2-City GeoLite2-Country"
      GEOIPUPDATE_FREQUENCY: "168"
    volumes:
      - geoip_data:/usr/share/GeoIP

volumes:
  geoip_data:

Hot-reload behavior

Scenario Behavior
No GEOIP_DB_PATH set "Unknown, Unknown" — no error
Empty directory "Unknown, Unknown"
DB file present Country + city resolved
DB updated by geoipupdate Next lookup automatically loads new DB
DB deleted Fallback to "Unknown"
Corrupt DB Error caught, "Unknown" returned

Test plan

  1. Without config: No GEOIP_DB_PATH → sessions show "Unknown, Unknown" (no crash)
  2. With GeoLite2-City.mmdb: Login → session shows correct country + city
  3. Private IPs: 10.x, 192.168.x, 100.64.x → "Local, Local Network"
  4. IPv6: Public IPv6 → resolved; ::1, fd:: → "Local, Local Network"
  5. Hot-reload: Update MMDB file while running → next lookup uses new data
  6. Country-only DB: Only GeoLite2-Country.mmdb → country resolved, city = "Unknown"

…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)
Copilot AI review requested due to automatic review settings March 23, 2026 00:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 maxmind dependency 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.

Comment thread backend/src/routes/authRoutes.js Outdated
Comment on lines +344 to +360
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;
}

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread backend/src/routes/authRoutes.js Outdated
Comment on lines +367 to +375
// 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

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
// 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)

Copilot uses AI. Check for mistakes.
Comment on lines 2274 to +2311
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),
};
}),
);

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread backend/src/routes/authRoutes.js Outdated
Comment on lines +348 to +355
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()})`,
);
}

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +340 to +356
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;

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread backend/src/routes/authRoutes.js Outdated
Comment on lines +329 to +345
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);

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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);

Copilot uses AI. Check for mistakes.
- 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")
@strausmann

Copy link
Copy Markdown
Author

Friendly ping — is there anything else needed from my side to get this reviewed? Happy to make adjustments if needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Implement GeoIP location lookup for session IPs (currently stub returning Unknown)

2 participants