Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
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;
};
@@ -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;
}