82 lines
2.9 KiB
TypeScript
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;
|
|
}
|