Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,27 @@
|
||||
import { IpAddressLog } from "@toolctrl/db/prisma-browser";
|
||||
import Constants from "~/application/Constants";
|
||||
import { PaginationDto } from "~/application/dtos/data/PaginationDto";
|
||||
import { db } from "~/utils/db.server";
|
||||
|
||||
type IpAddressLogWithDetails = IpAddressLog & {};
|
||||
export async function getAllIpAddressLogs(pagination?: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}): Promise<{ items: IpAddressLogWithDetails[]; pagination: PaginationDto }> {
|
||||
const items = await db.ipAddressLog.findMany({
|
||||
skip: pagination ? pagination?.pageSize * (pagination?.page - 1) : undefined,
|
||||
take: pagination ? pagination?.pageSize : undefined,
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
});
|
||||
const totalItems = await db.ipAddressLog.count({});
|
||||
|
||||
return {
|
||||
items,
|
||||
pagination: {
|
||||
page: pagination?.page ?? 1,
|
||||
pageSize: pagination?.pageSize ?? Constants.DEFAULT_PAGE_SIZE,
|
||||
totalItems,
|
||||
totalPages: Math.ceil(totalItems / (pagination?.pageSize ?? Constants.DEFAULT_PAGE_SIZE)),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { IpAddress } from "@toolctrl/db/prisma-browser";
|
||||
import Constants from "~/application/Constants";
|
||||
import { PaginationDto } from "~/application/dtos/data/PaginationDto";
|
||||
import { db } from "~/utils/db.server";
|
||||
|
||||
export async function getAllIpAddresses(pagination?: { page: number; pageSize: number }): Promise<{ items: IpAddress[]; pagination: PaginationDto }> {
|
||||
const items = await db.ipAddress.findMany({
|
||||
skip: pagination ? pagination?.pageSize * (pagination?.page - 1) : undefined,
|
||||
take: pagination ? pagination?.pageSize : undefined,
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
});
|
||||
const totalItems = await db.ipAddress.count({});
|
||||
|
||||
return {
|
||||
items,
|
||||
pagination: {
|
||||
page: pagination?.page ?? 1,
|
||||
pageSize: pagination?.pageSize ?? Constants.DEFAULT_PAGE_SIZE,
|
||||
totalItems,
|
||||
totalPages: Math.ceil(totalItems / (pagination?.pageSize ?? Constants.DEFAULT_PAGE_SIZE)),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export type IpAddressDto = {
|
||||
id: string;
|
||||
ip: string;
|
||||
provider: string;
|
||||
type: string;
|
||||
countryCode: string;
|
||||
countryName: string;
|
||||
regionCode: string;
|
||||
regionName: string;
|
||||
city: string;
|
||||
zipCode: string;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
metadata: { [key: string]: any } | null;
|
||||
};
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("~/utils/db.server", () => ({
|
||||
db: {
|
||||
ipAddress: {
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
ipAddressLog: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("~/utils/cache.server", () => ({
|
||||
cachified: vi.fn(({ getFreshValue }) => getFreshValue()),
|
||||
clearCacheKey: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/utils/server/IpUtils", () => ({ getClientIPAddress: vi.fn(() => "203.0.113.10") }));
|
||||
vi.mock("~/utils/db/blacklist.db.server", () => ({
|
||||
addBlacklistAttempt: vi.fn(),
|
||||
addToBlacklist: vi.fn(),
|
||||
findInBlacklist: vi.fn(() => null),
|
||||
}));
|
||||
// AD-22: GeoIP/Hashing wird hermetisch gemockt — hashIp ist deterministisch, Land-Auflösung ein No-op.
|
||||
vi.mock("./geoip.server", () => ({
|
||||
hashIp: (ip: string) => `hash(${ip})`,
|
||||
lookupCountry: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
import IpAddressServiceServer from "./IpAddressService.server";
|
||||
import { db } from "~/utils/db.server";
|
||||
import { addBlacklistAttempt, findInBlacklist } from "~/utils/db/blacklist.db.server";
|
||||
|
||||
const RAW_IP = "203.0.113.10";
|
||||
const HASHED_IP = `hash(${RAW_IP})`;
|
||||
|
||||
const dbMock = db as unknown as {
|
||||
ipAddress: {
|
||||
findUnique: ReturnType<typeof vi.fn>;
|
||||
upsert: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
ipAddressLog: {
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
const findInBlacklistMock = vi.mocked(findInBlacklist);
|
||||
const addBlacklistAttemptMock = vi.mocked(addBlacklistAttempt);
|
||||
|
||||
describe("IpAddressServiceServer", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
findInBlacklistMock.mockResolvedValue(null);
|
||||
dbMock.ipAddress.findUnique.mockResolvedValue(null);
|
||||
dbMock.ipAddress.upsert.mockResolvedValue({
|
||||
id: "ip_1",
|
||||
ip: HASHED_IP,
|
||||
provider: "",
|
||||
type: "",
|
||||
countryCode: "",
|
||||
countryName: "",
|
||||
regionCode: "",
|
||||
regionName: "",
|
||||
city: "",
|
||||
zipCode: "",
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
metadata: "null",
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
});
|
||||
dbMock.ipAddressLog.create.mockResolvedValue({ id: "log_1" });
|
||||
});
|
||||
|
||||
it("speichert nur den IP-Hash, nie die Klartext-IP (AD-22)", async () => {
|
||||
const request = new Request("https://admin.boiler.saas-ctrl.com/login");
|
||||
|
||||
await expect(
|
||||
IpAddressServiceServer.log(request, { action: "login", description: "admin@example.com" })
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(dbMock.ipAddress.upsert).toHaveBeenCalledWith({
|
||||
where: { ip: HASHED_IP },
|
||||
update: {},
|
||||
create: expect.objectContaining({ ip: HASHED_IP }),
|
||||
});
|
||||
const logArgs = dbMock.ipAddressLog.create.mock.calls[0][0];
|
||||
expect(logArgs.data.ip).toBe(HASHED_IP);
|
||||
expect(logArgs.data).toMatchObject({ ipAddressId: "ip_1", url: "/login", action: "login", success: true });
|
||||
});
|
||||
|
||||
it("ersetzt die Request-IP nicht durch eine fixe Dev-Adresse", async () => {
|
||||
const request = new Request("https://admin.boiler.saas-ctrl.com/login");
|
||||
|
||||
await expect(
|
||||
IpAddressServiceServer.log(request, { action: "login", description: "admin@example.com" })
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(dbMock.ipAddress.upsert).toHaveBeenCalledWith(expect.objectContaining({ where: { ip: HASHED_IP } }));
|
||||
expect(dbMock.ipAddress.upsert).not.toHaveBeenCalledWith(expect.objectContaining({ where: { ip: "hash(189.203.100.82)" } }));
|
||||
});
|
||||
|
||||
it("speichert stabile blacklisted-Fehlercodes mit gehashter IP", async () => {
|
||||
findInBlacklistMock.mockResolvedValue({ id: "blacklist_1" } as never);
|
||||
const request = new Request("https://admin.boiler.saas-ctrl.com/login");
|
||||
|
||||
await expect(
|
||||
IpAddressServiceServer.log(request, { action: "login", description: "admin@example.com" })
|
||||
).rejects.toThrow("blacklisted");
|
||||
|
||||
expect(addBlacklistAttemptMock).toHaveBeenCalledWith({ id: "blacklist_1" });
|
||||
expect(dbMock.ipAddressLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
ip: HASHED_IP,
|
||||
error: "blacklisted",
|
||||
success: false,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("speichert stabile unauthorized-Fehlercodes für provider-abgelehnte IP-Log-Zeilen", async () => {
|
||||
dbMock.ipAddress.findUnique.mockResolvedValue({
|
||||
id: "ip_1",
|
||||
ip: HASHED_IP,
|
||||
provider: "ipapi.is",
|
||||
type: "",
|
||||
countryCode: "",
|
||||
countryName: "",
|
||||
regionCode: "",
|
||||
regionName: "",
|
||||
city: "",
|
||||
zipCode: "",
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
metadata: JSON.stringify({ is_crawler: true }),
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
});
|
||||
const request = new Request("https://admin.boiler.saas-ctrl.com/login");
|
||||
|
||||
await expect(
|
||||
IpAddressServiceServer.log(request, { action: "login", description: "admin@example.com" })
|
||||
).rejects.toThrow("unauthorized");
|
||||
|
||||
expect(dbMock.ipAddressLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
ip: HASHED_IP,
|
||||
error: "unauthorized",
|
||||
success: false,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import { getClientIPAddress } from "~/utils/server/IpUtils";
|
||||
import { IpAddressDto } from "../dtos/IpAddressDto";
|
||||
import { cachified, clearCacheKey } from "~/utils/cache.server";
|
||||
import { db } from "~/utils/db.server";
|
||||
import { addBlacklistAttempt, addToBlacklist, findInBlacklist } from "~/utils/db/blacklist.db.server";
|
||||
import { createScopedLogger } from "~/utils/logger.server";
|
||||
import type { IpAddress } from "@toolctrl/db/prisma-browser";
|
||||
import { hashIp, lookupCountry } from "./geoip.server";
|
||||
|
||||
const ipAddressLogger = createScopedLogger("ipAddress");
|
||||
const LOCAL_REQUEST_IP = "127.0.0.1";
|
||||
type IpAddressLogErrorCode = "blacklisted" | "unauthorized";
|
||||
|
||||
async function log(
|
||||
request: Request,
|
||||
{
|
||||
action,
|
||||
description,
|
||||
metadata,
|
||||
block,
|
||||
}: {
|
||||
action: string;
|
||||
description: string;
|
||||
metadata?: { [key: string]: any };
|
||||
block?: IpAddressLogErrorCode;
|
||||
}
|
||||
) {
|
||||
const ip = getClientIPAddress(request)?.toString() || LOCAL_REQUEST_IP;
|
||||
// AD-22: nur der Pseudonym-Hash wird persistiert, nie die Klartext-IP.
|
||||
const storedIp = hashIp(ip);
|
||||
const ipAddress = await getOrCreateIpAddressLookup(ip);
|
||||
if (!ipAddress) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validationError = getIpAddressValidationError(ipAddress);
|
||||
|
||||
// Blacklist-Abgleich auf der Roh-IP — reine Laufzeit-Security-Liste, wird nicht in den Logs persistiert.
|
||||
let blacklistedIp = await findInBlacklist("ip", ip);
|
||||
if (!blacklistedIp && block) {
|
||||
blacklistedIp = await addToBlacklist({ type: "ip", value: ip });
|
||||
}
|
||||
const error = blacklistedIp ? "blacklisted" : validationError ?? block ?? null;
|
||||
if (error) {
|
||||
if (blacklistedIp) {
|
||||
await addBlacklistAttempt(blacklistedIp);
|
||||
}
|
||||
await db.ipAddressLog.create({
|
||||
data: {
|
||||
ipAddressId: ipAddress.id,
|
||||
ip: storedIp,
|
||||
url: new URL(request.url).pathname,
|
||||
action: action,
|
||||
description,
|
||||
metadata: null,
|
||||
error,
|
||||
success: false,
|
||||
},
|
||||
});
|
||||
throw Error(error);
|
||||
} else {
|
||||
await db.ipAddressLog.create({
|
||||
data: {
|
||||
ipAddressId: ipAddress.id || null,
|
||||
ip: storedIp,
|
||||
url: new URL(request.url).pathname,
|
||||
action,
|
||||
description,
|
||||
metadata: metadata ? JSON.stringify(metadata, null, 2) : null,
|
||||
error: null,
|
||||
success: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function getOrCreateIpAddressLookup(ip: string): Promise<IpAddressDto | null> {
|
||||
// AD-22: Schlüssel und gespeicherter Wert ist der Pseudonym-Hash; die Land-Auflösung passiert
|
||||
// lokal (DB-IP Lite) über die Roh-IP, die unsere Infra nie verlässt.
|
||||
const ipHash = hashIp(ip);
|
||||
const cacheKey = `ipAddress:${ipHash}`;
|
||||
const item = await cachified({
|
||||
key: cacheKey,
|
||||
disabled: isLocalRequestIp(ip),
|
||||
getFreshValue: () =>
|
||||
db.ipAddress.findUnique({
|
||||
where: { ip: ipHash },
|
||||
}),
|
||||
});
|
||||
if (item) {
|
||||
return toIpAddressDto(item);
|
||||
}
|
||||
|
||||
const geo = await lookupCountry(ip);
|
||||
|
||||
const dto: IpAddressDto = {
|
||||
id: "",
|
||||
ip: ipHash,
|
||||
provider: geo ? "local-db" : "",
|
||||
type: "",
|
||||
countryCode: geo?.countryCode ?? "",
|
||||
countryName: geo?.countryName ?? "",
|
||||
regionCode: "",
|
||||
regionName: "",
|
||||
city: "",
|
||||
zipCode: "",
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
metadata: null,
|
||||
};
|
||||
|
||||
const createdIp = await db.ipAddress
|
||||
.upsert({
|
||||
where: { ip: ipHash },
|
||||
update: {},
|
||||
create: {
|
||||
ip: ipHash,
|
||||
provider: dto.provider,
|
||||
type: dto.type,
|
||||
countryCode: dto.countryCode,
|
||||
countryName: dto.countryName,
|
||||
regionCode: dto.regionCode,
|
||||
regionName: dto.regionName,
|
||||
city: dto.city,
|
||||
zipCode: dto.zipCode,
|
||||
latitude: dto.latitude,
|
||||
longitude: dto.longitude,
|
||||
metadata: JSON.stringify(dto.metadata, null, 2),
|
||||
},
|
||||
})
|
||||
.then((created) => {
|
||||
clearCacheKey(cacheKey);
|
||||
return created;
|
||||
});
|
||||
|
||||
return toIpAddressDto(createdIp);
|
||||
}
|
||||
|
||||
function isLocalRequestIp(ip: string): boolean {
|
||||
return ip === LOCAL_REQUEST_IP || ip === "::1";
|
||||
}
|
||||
|
||||
function toIpAddressDto(item: IpAddress): IpAddressDto | null {
|
||||
try {
|
||||
return {
|
||||
id: item.id,
|
||||
ip: item.ip,
|
||||
provider: item.provider,
|
||||
type: item.type,
|
||||
countryCode: item.countryCode,
|
||||
countryName: item.countryName,
|
||||
regionCode: item.regionCode,
|
||||
regionName: item.regionName,
|
||||
city: item.city,
|
||||
zipCode: item.zipCode,
|
||||
latitude: item.latitude ? Number(item.latitude) : null,
|
||||
longitude: item.longitude ? Number(item.longitude) : null,
|
||||
metadata: JSON.parse(item.metadata),
|
||||
};
|
||||
} catch (error: any) {
|
||||
ipAddressLogger.error({ err: error }, "[getOrCreateIpAddressLookup] Error parsing metadata");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getIpAddressValidationError(ipAddress: IpAddressDto): IpAddressLogErrorCode | null {
|
||||
if (!ipAddress.metadata) {
|
||||
return null;
|
||||
}
|
||||
if (ipAddress.provider === "ipapi.is") {
|
||||
let data: IpApiIsDto = ipAddress.metadata as IpApiIsDto;
|
||||
ipAddressLogger.debug(
|
||||
{
|
||||
is_bogon: data.is_bogon,
|
||||
is_mobile: data.is_mobile,
|
||||
is_crawler: data.is_crawler,
|
||||
is_datacenter: data.is_datacenter,
|
||||
is_tor: data.is_tor,
|
||||
is_proxy: data.is_proxy,
|
||||
is_vpn: data.is_vpn,
|
||||
is_abuser: data.is_abuser,
|
||||
},
|
||||
"[validateIp]"
|
||||
);
|
||||
if (data.is_crawler) {
|
||||
return "unauthorized";
|
||||
}
|
||||
if (data.is_abuser) {
|
||||
return "unauthorized";
|
||||
}
|
||||
if (data.location) {
|
||||
const blockCountryCodes: string[] = [];
|
||||
if (blockCountryCodes.includes(data.location.country_code)) {
|
||||
return "unauthorized";
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Threat-Flags-Form des früheren ipapi.is-Providers. Bleibt als Vertrag für `getIpAddressValidationError`
|
||||
* bestehen (greift nur, wenn ein Datensatz noch ipapi.is-Metadaten trägt); neue Lookups setzen keine
|
||||
* Metadaten mehr (AD-22, lokale Land-Auflösung).
|
||||
*/
|
||||
type IpApiIsDto = {
|
||||
ip: string;
|
||||
rir: string;
|
||||
is_bogon: boolean;
|
||||
is_mobile: boolean;
|
||||
is_crawler: boolean;
|
||||
is_datacenter: boolean;
|
||||
is_tor: boolean;
|
||||
is_proxy: boolean;
|
||||
is_vpn: boolean;
|
||||
is_abuser: boolean;
|
||||
location?: {
|
||||
country: string;
|
||||
country_code: string;
|
||||
city: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
zip: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default {
|
||||
log,
|
||||
getOrCreateIpAddressLookup,
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@toolctrl/config", () => ({
|
||||
loadWebConfig: vi.fn(() => ({ config: { ipGeo: { hashPepper: "test-pepper", dbPath: undefined } } })),
|
||||
}));
|
||||
vi.mock("~/utils/logger.server", () => ({
|
||||
createScopedLogger: () => ({ warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { createHmac } from "node:crypto";
|
||||
import { hashIp, lookupCountry, __resetGeoipReaderForTests } from "./geoip.server";
|
||||
import { loadWebConfig } from "@toolctrl/config";
|
||||
|
||||
describe("geoip.hashIp", () => {
|
||||
beforeEach(() => {
|
||||
__resetGeoipReaderForTests();
|
||||
vi.mocked(loadWebConfig).mockReturnValue({ config: { ipGeo: { hashPepper: "test-pepper" } } } as never);
|
||||
});
|
||||
|
||||
it("pseudonymisiert die IP per HMAC-SHA256 mit Pepper (kein Klartext)", () => {
|
||||
const hashed = hashIp("203.0.113.10");
|
||||
expect(hashed).toBe(createHmac("sha256", "test-pepper").update("203.0.113.10").digest("hex"));
|
||||
expect(hashed).not.toContain("203.0.113.10");
|
||||
expect(hashed).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("ist deterministisch und trennt verschiedene IPs", () => {
|
||||
expect(hashIp("1.1.1.1")).toBe(hashIp("1.1.1.1"));
|
||||
expect(hashIp("1.1.1.1")).not.toBe(hashIp("1.1.1.2"));
|
||||
});
|
||||
|
||||
it("fällt ohne Pepper auf ungepepperten SHA-256 zurück (immer noch kein Klartext)", () => {
|
||||
vi.mocked(loadWebConfig).mockReturnValue({ config: { ipGeo: {} } } as never);
|
||||
const hashed = hashIp("203.0.113.10");
|
||||
expect(hashed).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(hashed).not.toContain("203.0.113.10");
|
||||
});
|
||||
});
|
||||
|
||||
describe("geoip.lookupCountry", () => {
|
||||
beforeEach(() => __resetGeoipReaderForTests());
|
||||
|
||||
it("löst das Land auf und bevorzugt den deutschen Namen", async () => {
|
||||
const reader = { get: () => ({ country: { iso_code: "DE", names: { de: "Deutschland", en: "Germany" } } }) };
|
||||
await expect(lookupCountry("203.0.113.10", reader)).resolves.toEqual({
|
||||
countryCode: "DE",
|
||||
countryName: "Deutschland",
|
||||
});
|
||||
});
|
||||
|
||||
it("fällt auf den englischen Namen zurück, wenn kein deutscher vorhanden ist", async () => {
|
||||
const reader = { get: () => ({ country: { iso_code: "US", names: { en: "United States" } } }) };
|
||||
await expect(lookupCountry("203.0.113.10", reader)).resolves.toEqual({
|
||||
countryCode: "US",
|
||||
countryName: "United States",
|
||||
});
|
||||
});
|
||||
|
||||
it("liefert null, wenn kein Land gefunden wird (Stub)", async () => {
|
||||
await expect(lookupCountry("203.0.113.10", { get: () => null })).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user