Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/modules/ipAddress/services/geoip.server.ts
T

82 lines
2.9 KiB
TypeScript

import { createHash, createHmac } from "node:crypto";
import { open } from "maxmind";
import { loadWebConfig } from "@toolctrl/config";
import { createScopedLogger } from "~/utils/logger.server";
/**
* GeoIP + IP-Pseudonymisierung (AD-22, EXT-12).
*
* - `hashIp` ersetzt die Klartext-IP vor jeder Persistierung durch einen HMAC-SHA256-Hash
* (Pepper aus den Coolify-Secrets, nicht in der DB). Gleichheits-Vergleich bleibt möglich.
* - `lookupCountry` löst NUR das Land lokal über eine DB-IP-Lite-MMDB auf — die IP verlässt
* unsere Infra nie. Fehlt die DB-Datei, ist die Auflösung ein No-op (Stub) ohne Fehler.
*/
const geoipLogger = createScopedLogger("geoip");
const DEFAULT_DB_PATH = "./data/geoip/dbip-country-lite.mmdb";
let warnedMissingPepper = false;
/** Pseudonymisiert eine Client-IP. Mit `IP_HASH_PEPPER` HMAC-SHA256, sonst ungepepperter SHA-256. */
export function hashIp(ip: string): string {
const pepper = loadWebConfig().config.ipGeo.hashPepper;
if (pepper) {
return createHmac("sha256", pepper).update(ip).digest("hex");
}
if (!warnedMissingPepper) {
warnedMissingPepper = true;
geoipLogger.warn("IP_HASH_PEPPER fehlt — nutze ungepepperten SHA-256. Pepper dringend empfohlen (AD-22).");
}
return createHash("sha256").update(ip).digest("hex");
}
export interface GeoCountry {
countryCode: string;
countryName: string;
}
/** Minimaler Reader-Vertrag (für Tests injizierbar, ohne maxmind/Dateisystem). */
export interface CountryReaderLike {
get(ip: string): { country?: { iso_code?: string; names?: Record<string, string> } } | null | undefined;
}
let readerPromise: Promise<CountryReaderLike | null> | null = null;
function loadReader(): Promise<CountryReaderLike | null> {
if (!readerPromise) {
const dbPath = loadWebConfig().config.ipGeo.dbPath || DEFAULT_DB_PATH;
readerPromise = open(dbPath)
.then((reader) => reader as unknown as CountryReaderLike)
.catch((err) => {
geoipLogger.warn({ err, dbPath }, "GeoIP-DB nicht ladbar — Land-Auflösung als Stub (No-op).");
return null;
});
}
return readerPromise;
}
/** Löst die Roh-IP lokal auf das Land auf. Gibt null zurück, wenn keine DB/kein Treffer (Stub). */
export async function lookupCountry(ip: string, reader?: CountryReaderLike): Promise<GeoCountry | null> {
try {
const activeReader = reader ?? (await loadReader());
if (!activeReader) {
return null;
}
const result = activeReader.get(ip);
const iso = result?.country?.iso_code;
if (!iso) {
return null;
}
const countryName = result?.country?.names?.de ?? result?.country?.names?.en ?? iso;
return { countryCode: iso, countryName };
} catch (err) {
geoipLogger.warn({ err }, "GeoIP-Lookup fehlgeschlagen.");
return null;
}
}
/** Test-Hilfe: setzt den memoisierten Reader zurück. */
export function __resetGeoipReaderForTests(): void {
readerPromise = null;
warnedMissingPepper = false;
}