Files
HealthLog/src/lib/geo.ts
T
Marc-André Bombeck 1f4ba5939d style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
2026-06-19 22:20:31 +02:00

434 lines
16 KiB
TypeScript

/**
* IP geolocation lookup for audit-log enrichment.
*
* v1.15.12 E2: online-first resolver. The baseline that always works is
* the `ipwho.is` HTTPS lookup (free, no key) — every self-host resolves
* a location out of the box with no MaxMind licence configured. The
* bundled MaxMind GeoLite2-City MMDB at `/opt/geolite2/GeoLite2-City.mmdb`
* is an OPTIONAL upgrade: when the offline databases are present
* (`offlineGeoReady()`), the resolver prefers the microsecond-scale
* local read for both speed and zero egress; when they are absent it
* goes straight online.
*
* 1. Private / loopback / opt-out → null (no lookup).
* 2. Per-IP cache hit → return immediately.
* 3. Offline MMDB present → local read; on a miss fall through online.
* 4. Otherwise → online `ipwho.is` lookup.
*
* The legacy contract still holds: `lookupIpLocation` returns a
* `"City, CC"` string or `null`, never throws. `lookupIpAsn` is the
* new sibling that resolves the autonomous-system number + the carrier
* organisation string (so the admin login overview can render a
* carrier chip next to the auth provider).
*
* Both helpers are safe to call from any request context. The MMDB
* Reader is loaded lazily on first call and held in a module-level
* cache; the file load is synchronous and not cheap, but it only
* happens once per worker process.
*
* The base path is overridable via `GEOLITE2_DIR` (default
* `/opt/geolite2`). When the DB files are missing the helpers silently
* skip the offline tier — local dev without the MMDBs still works and
* falls straight back to the online provider.
*
* Default provider for the online lookup is ipwho.is (HTTPS, free,
* no key). Both the response shape from ipwho.is and the fallback
* ip-api.com pro endpoint are accepted, so swapping providers via
* `IP_GEO_LOOKUP_URL` only requires matching one of those response
* shapes.
*
* Setting `IP_GEO_LOOKUP_DISABLED=1` disables the online lookup
* entirely — used by deployments that do not want any IP egress to a
* third-party service (V3 audit: GDPR Art. 32 + Art. 44, plaintext
* HTTP IP egress). With egress disabled the resolver leans solely on
* the offline MMDB tier (and returns null when that is also absent).
*
* Resolved locations are cached per IP in a small in-memory LRU
* (`LOCATION_CACHE`) so a burst of audit events from the same client
* does not hammer ipwho.is — the cache holds both hits and misses
* (negative caching) for a bounded TTL.
*
* v1.4.16 A8a: the online-fallback body is decoded as UTF-8 explicitly
* via `TextDecoder('utf-8')` instead of `Response.json()`, and an
* `Accept-Language: de, en;q=0.5` hint is sent so providers return
* native city names ("Nürnberg") rather than ASCII folds ("Nuremberg").
* The previous path lost umlauts in production for at least one
* maintainer-flagged login row that rendered as "Nrnberg" in
* /admin/login-overview — see `docs/audit/v1416-summary.md`.
*
* v1.4.27 R5: the build no longer hard-fails on a missing
* `MAXMIND_LICENSE_KEY` secret — the CI workflow drops an `.empty`
* marker into the geo asset directory when the key is unset.
* `offlineGeoReady()` is the canonical check used by `/api/version`
* and the admin status surface, and the lookup paths fire a one-shot
* admin notification on the first fallback so the maintainer hears
* about the gap from the running app.
*/
import fs from "node:fs";
import path from "node:path";
import { Reader as MmdbReader } from "mmdb-lib";
import type { AsnResponse, CityResponse } from "mmdb-lib/lib/reader/response";
import { getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
interface IpwhoIsResponse {
success?: boolean;
city?: string;
country_code?: string;
}
interface IpApiProResponse {
status?: "success" | "fail";
city?: string;
countryCode?: string;
}
type GeoResponse = IpwhoIsResponse & IpApiProResponse;
const PRIVATE_IP =
/^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|::1|fc|fd|fe80|localhost|unknown)/;
const DEFAULT_GEO_URL = "https://ipwho.is";
function geoLiteDir(): string {
return process.env.GEOLITE2_DIR ?? "/opt/geolite2";
}
// ── Per-IP location cache ────────────────────────────────────────────
//
// v1.15.12 E1: the online tier is now the baseline path, so a burst of
// audit events from the same address must not fan out one ipwho.is
// request each. We cache the resolved "City, CC" string per IP for a
// bounded TTL and also negative-cache misses (stored as null) so a
// non-resolving IP doesn't re-hit the provider every time. The map is a
// simple bounded FIFO — geo lookups are low-cardinality (a handful of
// distinct client IPs per worker) so an exact LRU is overkill.
const LOCATION_CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6 h
const LOCATION_CACHE_MAX = 512;
const LOCATION_CACHE = new Map<string, { value: string | null; at: number }>();
function getCachedLocation(ip: string): { value: string | null } | null {
const hit = LOCATION_CACHE.get(ip);
if (!hit) return null;
if (Date.now() - hit.at > LOCATION_CACHE_TTL_MS) {
LOCATION_CACHE.delete(ip);
return null;
}
return { value: hit.value };
}
function setCachedLocation(ip: string, value: string | null): void {
// Bound the map: drop the oldest insertion when full.
if (LOCATION_CACHE.size >= LOCATION_CACHE_MAX) {
const oldest = LOCATION_CACHE.keys().next().value;
if (oldest !== undefined) LOCATION_CACHE.delete(oldest);
}
LOCATION_CACHE.set(ip, { value, at: Date.now() });
}
// ── Offline tier (MaxMind GeoLite2) ──────────────────────────────────
//
// Both readers are held in a process-local cache. We use `undefined`
// for "never tried", and `null` for "tried, file missing" so we don't
// pay the `existsSync + readFileSync + parse` cost on every lookup.
interface MmdbCache {
city?: MmdbReader<CityResponse> | null;
asn?: MmdbReader<AsnResponse> | null;
}
const cache: MmdbCache = {};
function loadMmdbReader<
T extends import("mmdb-lib/lib/reader/response").Response,
>(file: string): MmdbReader<T> | null {
try {
const full = path.join(geoLiteDir(), file);
if (!fs.existsSync(full)) return null;
const buf = fs.readFileSync(full);
return new MmdbReader<T>(buf);
} catch {
// Corrupt MMDB or unreadable file → fall back to online tier.
// Never throw from the geo lookup path; the auth-audit caller is
// fire-and-forget and a thrown error would propagate into an
// unhandled rejection.
return null;
}
}
function getCityReader(): MmdbReader<CityResponse> | null {
if (cache.city === undefined) {
cache.city = loadMmdbReader<CityResponse>("GeoLite2-City.mmdb");
}
return cache.city;
}
function getAsnReader(): MmdbReader<AsnResponse> | null {
if (cache.asn === undefined) {
cache.asn = loadMmdbReader<AsnResponse>("GeoLite2-ASN.mmdb");
}
return cache.asn;
}
/**
* Test-only — reset the lazy reader cache so a test can swap the
* `GEOLITE2_DIR` between cases without leaking the previous Reader.
* Not exported from the public surface; the tests import via the
* module-level alias.
*/
export function __resetGeoLite2CacheForTests(): void {
cache.city = undefined;
cache.asn = undefined;
notifiedThisProcess = false;
LOCATION_CACHE.clear();
}
// ── Offline readiness + one-shot admin notification ─────────────────
//
// The CI workflow drops an `.empty` marker into the geo asset directory
// when the maintainer has not configured `MAXMIND_LICENSE_KEY`, in
// which case the City + ASN MMDBs are absent and every lookup hits the
// online fallback. `offlineGeoReady()` is the canonical truth used by
// `/api/version` and the admin status surface so the two paths cannot
// disagree.
//
// `notifiedThisProcess` is a module-level latch so the admin only
// receives the "offline geo is disabled" notification once per worker
// boot — every subsequent fallback hit is silent. Test-only reset is
// folded into the cache reset above.
let notifiedThisProcess = false;
export function offlineGeoReady(): boolean {
try {
const dir = geoLiteDir();
if (fs.existsSync(path.join(dir, ".empty"))) return false;
return fs.existsSync(path.join(dir, "GeoLite2-City.mmdb"));
} catch {
return false;
}
}
async function notifyOfflineGeoUnavailable(): Promise<void> {
if (notifiedThisProcess) return;
notifiedThisProcess = true;
// Defer the resolve so we don't pay the cost of pulling the
// Prisma client into the import graph until the first fallback —
// most calls are cache hits or private-IP short-circuits.
try {
const [{ prisma }, { dispatchLocalisedNotification }] = await Promise.all([
import("@/lib/db"),
import("@/lib/notifications/dispatch-localised"),
]);
const admins = await prisma.user.findMany({
where: { role: "ADMIN" },
select: { id: true },
});
if (admins.length === 0) {
getEvent()?.addWarning(
"geo: offline databases unavailable, no admin user configured to notify",
);
return;
}
getEvent()?.addWarning(
"geo: offline databases unavailable, falling back to ipwho.is — notifying admins",
);
for (const admin of admins) {
await dispatchLocalisedNotification({
userId: admin.id,
titleKey: "notifications.admin.offlineGeoUnavailableTitle",
messageKey: "notifications.admin.offlineGeoUnavailableBody",
params: {
secretsUrl:
"https://github.com/MBombeck/HealthLog/settings/secrets/actions",
},
metadata: { source: "geo-offline-detection" },
});
}
} catch (err) {
// Never let a notification failure propagate — the auth-audit
// caller is fire-and-forget.
getEvent()?.addWarning(
`geo: offline-geo notification failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
/**
* Pick the best-available localised city name from a GeoLite2
* record. German first because the user-base is DACH-skewed; English
* as a graceful fallback for cities that don't have a German exonym.
* The Names map is incomplete in MaxMind for some small German
* towns, so we accept any of the locales the MMDB layout exposes.
*/
function pickCityName(city: CityResponse["city"]): string | null {
if (!city?.names) return null;
return (
city.names.de ?? city.names.en ?? city.names.fr ?? city.names.es ?? null
);
}
function lookupIpLocationOffline(ip: string): string | null {
const reader = getCityReader();
if (!reader) return null;
try {
const row = reader.get(ip);
if (!row) return null;
const city = pickCityName(row.city);
const cc = row.country?.iso_code ?? row.registered_country?.iso_code;
if (!city || !cc) return null;
return `${city}, ${cc}`;
} catch {
return null;
}
}
function buildLookupUrl(ip: string): string {
const base = (process.env.IP_GEO_LOOKUP_URL ?? DEFAULT_GEO_URL).replace(
/\/+$/,
"",
);
if (!base.startsWith("https://")) {
// V3 audit: never leak audit-event IPs over plaintext HTTP. Reject any
// configuration that would do so by upgrading to https; if the operator
// has explicitly opted into HTTP via env, we still refuse and return a
// dummy URL the parser will fail on.
return `https://invalid.invalid/refused-non-https/${encodeURIComponent(ip)}`;
}
return `${base}/${encodeURIComponent(ip)}`;
}
/**
* Decode the raw response bytes as UTF-8, then JSON-parse. Bypasses
* `Response.json()` so the result is independent of the upstream
* `Content-Type: application/json; charset=…` value — relevant in
* production where ipwho.is sits behind Cloudflare and an intermediate
* proxy can re-serve the body without preserving the charset hint.
* Falls back to whatever decoding errors `TextDecoder` produces (which
* substitute U+FFFD rather than dropping bytes), so a malformed
* response surfaces as a parse failure instead of silent character
* loss.
*/
async function readUtf8Json(res: Response): Promise<unknown> {
const buf = await res.arrayBuffer();
const text = new TextDecoder("utf-8").decode(buf);
return JSON.parse(text);
}
async function lookupIpLocationOnline(ip: string): Promise<string | null> {
if (process.env.IP_GEO_LOOKUP_DISABLED === "1") return null;
try {
const res = await safeFetch(
buildLookupUrl(ip),
{
headers: {
// ipwho.is and ip-api both honour Accept-Language for the
// city field. Without the hint, ip-api falls back to the
// English ASCII fold ("Nuremberg" instead of "Nürnberg").
// German first because the user-base is DACH-skewed; English
// as a graceful fallback for cities that don't have a German
// name. Quality 0.5 on the en fallback keeps providers from
// tie-breaking the wrong way.
"Accept-Language": "de, en;q=0.5",
Accept: "application/json",
},
},
// v1.11.2 — the lookup URL comes from the operator IP_GEO_LOOKUP_URL env;
// pin the connect-time DNS check so it can't be pointed at a private /
// metadata address (SSRF/rebinding).
// v1.15.12 E1 — keep the online timeout below the audit-log 3 s race
// window so a slow-but-successful lookup still lands before the race
// resolves null and drops the location (the "—" symptom on apps01).
{ timeoutMs: 2_500, requirePublicHost: true },
);
if (!res.ok) return null;
const data = (await readUtf8Json(res)) as GeoResponse;
const ok =
data.success === true ||
data.status === "success" ||
(data.city && (data.country_code ?? data.countryCode));
if (!ok) return null;
const city = data.city;
const country = data.country_code ?? data.countryCode;
if (!city || !country) return null;
return `${city}, ${country}`;
} catch {
return null;
}
}
export async function lookupIpLocation(
ip: string | null,
): Promise<string | null> {
if (!ip || PRIVATE_IP.test(ip)) return null;
const cached = getCachedLocation(ip);
if (cached) return cached.value;
// v1.15.12 E2 — online-first precedence. The optional MaxMind offline
// tier is preferred ONLY when its databases are actually present
// (`offlineGeoReady()`): a local hit is faster and egress-free. When
// the offline DBs are absent — the default for a self-host with no
// `MAXMIND_LICENSE_KEY` — we skip straight to the online provider,
// which is the baseline that always resolves a location.
if (offlineGeoReady()) {
const offline = lookupIpLocationOffline(ip);
if (offline) {
setCachedLocation(ip, offline);
return offline;
}
// Offline DBs present but this IP missed (freshly-allocated range
// lagging the monthly GeoLite2 roll) → fall through to online.
} else {
// No offline tier configured — fire the one-shot admin alert so the
// maintainer can wire `MAXMIND_LICENSE_KEY` for the offline upgrade
// when convenient. Online still resolves; the alert is informational.
// Fire-and-forget so the audit-log path stays fast.
void notifyOfflineGeoUnavailable();
}
const online = await lookupIpLocationOnline(ip);
setCachedLocation(ip, online);
return online;
}
/**
* Resolve the autonomous-system number + carrier organisation for an
* IP. Offline-only against the bundled GeoLite2-ASN MMDB — the public
* `ipwho.is` endpoint does not expose an ASN field in its free tier,
* so a miss returns `null` rather than falling back to an online
* lookup.
*
* Private / loopback IPs return `null` without touching the reader.
* Same gate the location helper applies.
*/
export function lookupIpAsn(
ip: string | null,
): { asn: number; carrier: string | null } | null {
if (!ip || PRIVATE_IP.test(ip)) return null;
const reader = getAsnReader();
if (!reader) {
// No offline ASN data — alert once per process. There is no online
// fallback for ASN, but the maintainer still wants to know the
// carrier chip is going to stay empty until the secret is set.
if (!offlineGeoReady()) {
void notifyOfflineGeoUnavailable();
}
return null;
}
try {
const row = reader.get(ip);
if (!row) return null;
const asn = row.autonomous_system_number;
if (typeof asn !== "number") return null;
const carrier = row.autonomous_system_organization || null;
return { asn, carrier };
} catch {
return null;
}
}