feat(api): Agent-API mit Enrollment, Report und Ping (#004)
Gehashte Tokens mit Praefix, Rate-Limit je IP und Token, zod-Validierung mit durchgaengig optionalen Feldern, idempotente Reports ueber reportId, Key- und Server-ID-Historie sowie automatischer Laufzeit-Vorschlag. 32 Tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
AGENT_VERSION,
|
||||
bearerToken,
|
||||
enrollRateLimit,
|
||||
fehlerAntwort,
|
||||
fehlerBehandeln,
|
||||
jsonEingabe,
|
||||
rateLimitPruefen,
|
||||
} from "@/server/agent/http";
|
||||
import { enrollSchema } from "@/server/agent/schema";
|
||||
import { registriereAgent } from "@/server/agent/service";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Registrierung eines Agents mit einem Enrollment-Token. */
|
||||
export async function POST(request: Request) {
|
||||
const token = bearerToken(request);
|
||||
const gebremst = rateLimitPruefen(enrollRateLimit, request.headers, token);
|
||||
if (gebremst) return gebremst;
|
||||
|
||||
if (!token) {
|
||||
return fehlerAntwort(401, "token_fehlt", "Enrollment-Token fehlt (Authorization: Bearer …).");
|
||||
}
|
||||
|
||||
const eingabe = await jsonEingabe(request, enrollSchema);
|
||||
if ("antwort" in eingabe) return eingabe.antwort;
|
||||
|
||||
try {
|
||||
const ergebnis = await registriereAgent(eingabe.daten, token);
|
||||
return Response.json(
|
||||
{
|
||||
ok: true,
|
||||
hostId: ergebnis.hostId,
|
||||
token: ergebnis.token,
|
||||
bestehenderHost: ergebnis.bestehenderHost,
|
||||
serverIdBereitsBekannt: ergebnis.serverIdBereitsBekannt,
|
||||
agentVersion: AGENT_VERSION,
|
||||
serverZeit: new Date().toISOString(),
|
||||
},
|
||||
{ status: ergebnis.bestehenderHost ? 200 : 201 },
|
||||
);
|
||||
} catch (fehler) {
|
||||
return fehlerBehandeln(fehler);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
AGENT_VERSION,
|
||||
agentRateLimit,
|
||||
bearerToken,
|
||||
fehlerAntwort,
|
||||
fehlerBehandeln,
|
||||
rateLimitPruefen,
|
||||
} from "@/server/agent/http";
|
||||
import { authentifiziereHost } from "@/server/agent/service";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Verbindungstest für `proxledger-agent --test`. */
|
||||
export async function GET(request: Request) {
|
||||
const token = bearerToken(request);
|
||||
const gebremst = rateLimitPruefen(agentRateLimit, request.headers, token);
|
||||
if (gebremst) return gebremst;
|
||||
|
||||
if (!token) {
|
||||
return fehlerAntwort(401, "token_fehlt", "Host-Token fehlt (Authorization: Bearer …).");
|
||||
}
|
||||
|
||||
try {
|
||||
const host = await authentifiziereHost(token);
|
||||
return Response.json({
|
||||
ok: true,
|
||||
hostId: host.id,
|
||||
hostname: host.hostname,
|
||||
agentVersion: AGENT_VERSION,
|
||||
serverZeit: new Date().toISOString(),
|
||||
});
|
||||
} catch (fehler) {
|
||||
return fehlerBehandeln(fehler);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { clientIp } from "@/lib/ratelimit";
|
||||
import {
|
||||
AGENT_VERSION,
|
||||
agentRateLimit,
|
||||
bearerToken,
|
||||
fehlerAntwort,
|
||||
fehlerBehandeln,
|
||||
jsonEingabe,
|
||||
rateLimitPruefen,
|
||||
} from "@/server/agent/http";
|
||||
import { reportSchema } from "@/server/agent/schema";
|
||||
import { authentifiziereHost, verarbeiteReport } from "@/server/agent/service";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Täglicher Statusbericht eines Hosts. */
|
||||
export async function POST(request: Request) {
|
||||
const token = bearerToken(request);
|
||||
const gebremst = rateLimitPruefen(agentRateLimit, request.headers, token);
|
||||
if (gebremst) return gebremst;
|
||||
|
||||
if (!token) {
|
||||
return fehlerAntwort(401, "token_fehlt", "Host-Token fehlt (Authorization: Bearer …).");
|
||||
}
|
||||
|
||||
const eingabe = await jsonEingabe(request, reportSchema);
|
||||
if ("antwort" in eingabe) return eingabe.antwort;
|
||||
|
||||
try {
|
||||
const host = await authentifiziereHost(token);
|
||||
const ergebnis = await verarbeiteReport(host, eingabe.daten, clientIp(request.headers));
|
||||
|
||||
return Response.json({
|
||||
ok: true,
|
||||
hostId: host.id,
|
||||
doppelt: ergebnis.doppelt,
|
||||
laufzeitVorschlag: ergebnis.laufzeitVorschlagAngelegt,
|
||||
agentVersion: AGENT_VERSION,
|
||||
serverZeit: new Date().toISOString(),
|
||||
});
|
||||
} catch (fehler) {
|
||||
return fehlerBehandeln(fehler);
|
||||
}
|
||||
}
|
||||
+15
-7
@@ -2,8 +2,10 @@ import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* Prisma-Client als Singleton. Im Entwicklungsmodus lädt Next.js Module neu,
|
||||
* ohne den Prozess zu beenden – ohne Singleton entstünden dabei immer neue Verbindungspools.
|
||||
* Prisma-Client als Singleton, der erst beim ersten Zugriff verbindet.
|
||||
* Das ist wichtig, weil Next.js beim Bauen Module lädt, ohne dass `DATABASE_URL` gesetzt sein muss.
|
||||
* Im Entwicklungsmodus verhindert der globale Zwischenspeicher, dass jeder Hot-Reload
|
||||
* einen neuen Verbindungspool öffnet.
|
||||
*/
|
||||
declare global {
|
||||
var proxledgerPrisma: PrismaClient | undefined;
|
||||
@@ -14,11 +16,17 @@ function erzeugeClient(): PrismaClient {
|
||||
if (!connectionString) {
|
||||
throw new Error("DATABASE_URL ist nicht gesetzt. Beispielwerte stehen in .env.example.");
|
||||
}
|
||||
return new PrismaClient({ adapter: new PrismaPg({ connectionString }) });
|
||||
const client = new PrismaClient({ adapter: new PrismaPg({ connectionString }) });
|
||||
if (process.env.NODE_ENV !== "production") globalThis.proxledgerPrisma = client;
|
||||
return client;
|
||||
}
|
||||
|
||||
export const prisma: PrismaClient = globalThis.proxledgerPrisma ?? erzeugeClient();
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalThis.proxledgerPrisma = prisma;
|
||||
export function getPrisma(): PrismaClient {
|
||||
return globalThis.proxledgerPrisma ?? erzeugeClient();
|
||||
}
|
||||
|
||||
export const prisma: PrismaClient = new Proxy({} as PrismaClient, {
|
||||
get(_ziel, eigenschaft, empfaenger) {
|
||||
return Reflect.get(getPrisma(), eigenschaft, empfaenger);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Einfaches Token-Bucket-Rate-Limit im Prozessspeicher.
|
||||
* Für eine einzelne Instanz ausreichend; bei mehreren Instanzen müsste der Zustand
|
||||
* in die Datenbank oder einen gemeinsamen Cache wandern (siehe #014).
|
||||
*/
|
||||
|
||||
interface Eimer {
|
||||
tokens: number;
|
||||
zuletzt: number;
|
||||
}
|
||||
|
||||
export interface RateLimitErgebnis {
|
||||
erlaubt: boolean;
|
||||
/** Sekunden bis zum nächsten erlaubten Versuch (nur wenn abgelehnt). */
|
||||
wiederholenNach: number;
|
||||
verbleibend: number;
|
||||
}
|
||||
|
||||
export class RateLimit {
|
||||
private eimer = new Map<string, Eimer>();
|
||||
|
||||
constructor(
|
||||
private readonly kapazitaet: number,
|
||||
private readonly nachfuellProSekunde: number,
|
||||
) {}
|
||||
|
||||
pruefe(schluessel: string, jetzt: number = Date.now()): RateLimitErgebnis {
|
||||
const vorhanden = this.eimer.get(schluessel) ?? { tokens: this.kapazitaet, zuletzt: jetzt };
|
||||
const vergangen = Math.max(0, (jetzt - vorhanden.zuletzt) / 1000);
|
||||
const tokens = Math.min(this.kapazitaet, vorhanden.tokens + vergangen * this.nachfuellProSekunde);
|
||||
|
||||
if (tokens < 1) {
|
||||
this.eimer.set(schluessel, { tokens, zuletzt: jetzt });
|
||||
return {
|
||||
erlaubt: false,
|
||||
wiederholenNach: Math.ceil((1 - tokens) / this.nachfuellProSekunde),
|
||||
verbleibend: 0,
|
||||
};
|
||||
}
|
||||
|
||||
this.eimer.set(schluessel, { tokens: tokens - 1, zuletzt: jetzt });
|
||||
return { erlaubt: true, wiederholenNach: 0, verbleibend: Math.floor(tokens - 1) };
|
||||
}
|
||||
|
||||
/** Entfernt Einträge, die wieder voll aufgefüllt sind (Aufräumen bei vielen Hosts). */
|
||||
aufraeumen(jetzt: number = Date.now()) {
|
||||
const vollNach = (this.kapazitaet / this.nachfuellProSekunde) * 1000;
|
||||
for (const [schluessel, eimer] of this.eimer) {
|
||||
if (jetzt - eimer.zuletzt > vollNach) this.eimer.delete(schluessel);
|
||||
}
|
||||
}
|
||||
|
||||
zuruecksetzen() {
|
||||
this.eimer.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/** 10 Anfragen pro Minute je Token bzw. IP – ein Agent meldet sich einmal täglich. */
|
||||
export const agentRateLimit = new RateLimit(10, 10 / 60);
|
||||
|
||||
/** Registrierungen sind seltener und werden strenger begrenzt. */
|
||||
export const enrollRateLimit = new RateLimit(5, 5 / 60);
|
||||
|
||||
/** Ermittelt die Client-IP hinter einem Reverse Proxy. */
|
||||
export function clientIp(headers: Headers): string {
|
||||
const forwarded = headers.get("x-forwarded-for");
|
||||
if (forwarded) return forwarded.split(",")[0].trim();
|
||||
return headers.get("x-real-ip") ?? "unbekannt";
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
|
||||
/**
|
||||
* API-Tokens für Agents. In der Datenbank liegt ausschließlich der SHA-256-Hash:
|
||||
* Die Tokens haben volle Entropie (32 Byte Zufall), deshalb ist kein langsames
|
||||
* Passwort-Hashing nötig – SHA-256 erlaubt zudem das direkte Nachschlagen per Index.
|
||||
*/
|
||||
|
||||
export const PRAEFIX_ENROLLMENT = "plx_e_";
|
||||
export const PRAEFIX_HOST = "plx_h_";
|
||||
|
||||
export interface ErzeugterToken {
|
||||
/** Vollständiger Token im Klartext – wird nur einmal ausgegeben. */
|
||||
klartext: string;
|
||||
hash: string;
|
||||
/** Erkennbarer Anfang für Anzeige und Protokoll, z. B. `plx_h_9f3a2b`. */
|
||||
praefix: string;
|
||||
}
|
||||
|
||||
export function erzeugeToken(typ: "enrollment" | "host"): ErzeugterToken {
|
||||
const kennung = typ === "enrollment" ? PRAEFIX_ENROLLMENT : PRAEFIX_HOST;
|
||||
const zufall = randomBytes(32).toString("base64url");
|
||||
const klartext = `${kennung}${zufall}`;
|
||||
return {
|
||||
klartext,
|
||||
hash: hashToken(klartext),
|
||||
praefix: klartext.slice(0, kennung.length + 6),
|
||||
};
|
||||
}
|
||||
|
||||
export function hashToken(klartext: string): string {
|
||||
return createHash("sha256").update(klartext.trim()).digest("hex");
|
||||
}
|
||||
|
||||
/** Vergleicht zwei Hashes ohne zeitliche Seitenkanäle. */
|
||||
export function hashGleich(a: string, b: string): boolean {
|
||||
const links = Buffer.from(a, "hex");
|
||||
const rechts = Buffer.from(b, "hex");
|
||||
if (links.length !== rechts.length || links.length === 0) return false;
|
||||
return timingSafeEqual(links, rechts);
|
||||
}
|
||||
|
||||
/** Liest den Bearer-Token aus dem Authorization-Header. */
|
||||
export function tokenAusHeader(header: string | null): string | null {
|
||||
if (!header) return null;
|
||||
const treffer = /^Bearer\s+(.+)$/i.exec(header.trim());
|
||||
return treffer ? treffer[1].trim() : null;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { ZodType } from "zod";
|
||||
import { agentRateLimit, clientIp, enrollRateLimit, type RateLimit } from "@/lib/ratelimit";
|
||||
import { tokenAusHeader } from "@/lib/tokens";
|
||||
import { AgentApiFehler } from "./service";
|
||||
|
||||
/** Version des Agent-Skripts, die das Dashboard ausliefert (siehe #005). */
|
||||
export const AGENT_VERSION = "0.2.0";
|
||||
|
||||
export function fehlerAntwort(status: number, code: string, nachricht: string, extra?: HeadersInit) {
|
||||
return Response.json({ ok: false, code, fehler: nachricht }, { status, headers: extra });
|
||||
}
|
||||
|
||||
/** Wandelt geworfene Fehler in eine JSON-Antwort, ohne interne Details preiszugeben. */
|
||||
export function fehlerBehandeln(fehler: unknown) {
|
||||
if (fehler instanceof AgentApiFehler) {
|
||||
return fehlerAntwort(fehler.status, fehler.code, fehler.message);
|
||||
}
|
||||
console.error("Agent-API: unerwarteter Fehler", fehler);
|
||||
return fehlerAntwort(500, "interner_fehler", "Interner Fehler.");
|
||||
}
|
||||
|
||||
/** Prüft das Rate-Limit für IP und (falls vorhanden) Token. */
|
||||
export function rateLimitPruefen(
|
||||
limit: RateLimit,
|
||||
headers: Headers,
|
||||
token: string | null,
|
||||
): Response | null {
|
||||
const schluessel = token ? `token:${token.slice(0, 16)}` : `ip:${clientIp(headers)}`;
|
||||
const ergebnis = limit.pruefe(schluessel);
|
||||
if (ergebnis.erlaubt) return null;
|
||||
return fehlerAntwort(429, "zu_viele_anfragen", "Zu viele Anfragen.", {
|
||||
"Retry-After": String(ergebnis.wiederholenNach),
|
||||
});
|
||||
}
|
||||
|
||||
export { agentRateLimit, enrollRateLimit };
|
||||
|
||||
export function bearerToken(request: Request): string | null {
|
||||
return tokenAusHeader(request.headers.get("authorization"));
|
||||
}
|
||||
|
||||
/** Liest und validiert den JSON-Rumpf; liefert bei Fehlern direkt eine Antwort. */
|
||||
export async function jsonEingabe<T>(
|
||||
request: Request,
|
||||
schema: ZodType<T>,
|
||||
): Promise<{ daten: T } | { antwort: Response }> {
|
||||
let rohdaten: unknown;
|
||||
try {
|
||||
rohdaten = await request.json();
|
||||
} catch {
|
||||
return { antwort: fehlerAntwort(400, "kein_json", "Der Anfragekörper ist kein gültiges JSON.") };
|
||||
}
|
||||
|
||||
const ergebnis = schema.safeParse(rohdaten);
|
||||
if (!ergebnis.success) {
|
||||
const felder = ergebnis.error.issues.map((problem) => ({
|
||||
feld: problem.path.join("."),
|
||||
meldung: problem.message,
|
||||
}));
|
||||
return {
|
||||
antwort: Response.json(
|
||||
{ ok: false, code: "validierung", fehler: "Eingabe ist ungültig.", felder },
|
||||
{ status: 422 },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return { daten: ergebnis.data };
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { z } from "zod";
|
||||
import type { Produkt, SubscriptionStatus } from "@/generated/prisma/enums";
|
||||
|
||||
/**
|
||||
* Schemas für die Agent-API. Grundregel aus dem Auftrag: Ein Report darf nie an fehlenden
|
||||
* Feldern scheitern – alles außer den Metadaten ist `nullable` und darf auch ganz fehlen.
|
||||
*/
|
||||
|
||||
const produkt = z.enum(["pve", "pbs", "pmg"]);
|
||||
|
||||
/** Proxmox liefert Datumsangaben als `YYYY-MM-DD`, Zeitstempel teils als Unix-Sekunden. */
|
||||
const datumWert = z.union([z.string(), z.number()]).nullish();
|
||||
|
||||
const nullbarerText = z.string().max(500).nullish();
|
||||
|
||||
export const enrollSchema = z.object({
|
||||
/** Stabile Kennung des Agents, abgeleitet aus /etc/machine-id. */
|
||||
agentId: z.string().min(8).max(128),
|
||||
hostname: z.string().min(1).max(255),
|
||||
fqdn: nullbarerText,
|
||||
produkt: produkt,
|
||||
serverId: nullbarerText,
|
||||
agentVersion: nullbarerText,
|
||||
});
|
||||
|
||||
export const reportSchema = z.object({
|
||||
/** UUID des Agents; macht nachgesendete Reports idempotent. */
|
||||
reportId: z.string().min(8).max(64),
|
||||
agentVersion: nullbarerText,
|
||||
erhobenAm: z.string().datetime({ offset: true }),
|
||||
|
||||
host: z
|
||||
.object({
|
||||
hostname: nullbarerText,
|
||||
fqdn: nullbarerText,
|
||||
ips: z.array(z.string().max(64)).max(32).nullish(),
|
||||
clusterName: nullbarerText,
|
||||
nodeName: nullbarerText,
|
||||
kernel: nullbarerText,
|
||||
uptimeSekunden: z.number().int().nonnegative().nullish(),
|
||||
cpuModell: nullbarerText,
|
||||
sockets: z.number().int().positive().max(64).nullish(),
|
||||
paketUpdates: z.number().int().nonnegative().nullish(),
|
||||
})
|
||||
.partial()
|
||||
.default({}),
|
||||
|
||||
produkt: z
|
||||
.object({
|
||||
typ: produkt.nullish(),
|
||||
version: nullbarerText,
|
||||
})
|
||||
.partial()
|
||||
.default({}),
|
||||
|
||||
subscription: z
|
||||
.object({
|
||||
key: nullbarerText,
|
||||
status: nullbarerText,
|
||||
level: nullbarerText,
|
||||
productname: nullbarerText,
|
||||
sockets: z.number().int().nonnegative().max(64).nullish(),
|
||||
serverid: nullbarerText,
|
||||
regdate: datumWert,
|
||||
nextduedate: datumWert,
|
||||
checktime: datumWert,
|
||||
})
|
||||
.partial()
|
||||
.default({}),
|
||||
});
|
||||
|
||||
export type EnrollEingabe = z.infer<typeof enrollSchema>;
|
||||
export type ReportEingabe = z.infer<typeof reportSchema>;
|
||||
|
||||
const STATUS_ABBILDUNG: Record<string, SubscriptionStatus> = {
|
||||
active: "ACTIVE",
|
||||
new: "ACTIVE",
|
||||
notfound: "NOTFOUND",
|
||||
invalid: "INVALID",
|
||||
expired: "EXPIRED",
|
||||
suspended: "SUSPENDED",
|
||||
};
|
||||
|
||||
/** Unbekannte Statuswerte werden zu UNKNOWN, damit ein Report nie abgelehnt wird. */
|
||||
export function statusAusProxmox(wert: string | null | undefined): SubscriptionStatus {
|
||||
if (!wert) return "UNKNOWN";
|
||||
return STATUS_ABBILDUNG[wert.trim().toLowerCase()] ?? "UNKNOWN";
|
||||
}
|
||||
|
||||
export function produktAusEingabe(wert: "pve" | "pbs" | "pmg" | null | undefined): Produkt | null {
|
||||
if (!wert) return null;
|
||||
return wert === "pve" ? "PVE" : wert === "pbs" ? "PBS" : "PMG";
|
||||
}
|
||||
|
||||
/**
|
||||
* Wandelt Datumsangaben von Proxmox um: `YYYY-MM-DD`, ISO-Zeitstempel oder Unix-Sekunden.
|
||||
* Ungültige Werte ergeben `null` statt eines Fehlers.
|
||||
*/
|
||||
export function datumAusProxmox(wert: string | number | null | undefined): Date | null {
|
||||
if (wert === null || wert === undefined || wert === "") return null;
|
||||
|
||||
if (typeof wert === "number") {
|
||||
const ausSekunden = new Date(wert * 1000);
|
||||
return Number.isNaN(ausSekunden.getTime()) ? null : ausSekunden;
|
||||
}
|
||||
|
||||
const text = wert.trim();
|
||||
if (/^\d+$/.test(text)) return datumAusProxmox(Number(text));
|
||||
|
||||
const datum = /^\d{4}-\d{2}-\d{2}$/.test(text) ? new Date(`${text}T00:00:00Z`) : new Date(text);
|
||||
return Number.isNaN(datum.getTime()) ? null : datum;
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import type { HostModel } from "@/generated/prisma/models";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { erzeugeToken, hashToken } from "@/lib/tokens";
|
||||
import {
|
||||
datumAusProxmox,
|
||||
produktAusEingabe,
|
||||
statusAusProxmox,
|
||||
type EnrollEingabe,
|
||||
type ReportEingabe,
|
||||
} from "./schema";
|
||||
|
||||
export class AgentApiFehler extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
nachricht: string,
|
||||
) {
|
||||
super(nachricht);
|
||||
}
|
||||
}
|
||||
|
||||
export interface EnrollErgebnis {
|
||||
hostId: string;
|
||||
token: string;
|
||||
/** Wahr, wenn die Server-ID bereits zu einem anderen Host gehörte (Neuinstallation oder Klon). */
|
||||
serverIdBereitsBekannt: boolean;
|
||||
/** Wahr, wenn sich ein bereits registrierter Agent erneut meldet (z. B. nach Neuinstallation des Agents). */
|
||||
bestehenderHost: boolean;
|
||||
}
|
||||
|
||||
/** Registriert einen Agent und gibt ihm einen host-spezifischen Token. */
|
||||
export async function registriereAgent(
|
||||
eingabe: EnrollEingabe,
|
||||
enrollmentTokenKlartext: string,
|
||||
): Promise<EnrollErgebnis> {
|
||||
const hash = hashToken(enrollmentTokenKlartext);
|
||||
const enrollToken = await prisma.enrollmentToken.findUnique({ where: { tokenHash: hash } });
|
||||
|
||||
if (!enrollToken || enrollToken.widerrufenAm) {
|
||||
throw new AgentApiFehler(401, "token_ungueltig", "Enrollment-Token ist ungültig oder widerrufen.");
|
||||
}
|
||||
if (enrollToken.gueltigBis && enrollToken.gueltigBis.getTime() < Date.now()) {
|
||||
throw new AgentApiFehler(401, "token_abgelaufen", "Enrollment-Token ist abgelaufen.");
|
||||
}
|
||||
if (enrollToken.maxNutzungen !== null && enrollToken.nutzungen >= enrollToken.maxNutzungen) {
|
||||
throw new AgentApiFehler(401, "token_aufgebraucht", "Enrollment-Token wurde bereits zu oft verwendet.");
|
||||
}
|
||||
|
||||
const produkt = produktAusEingabe(eingabe.produkt);
|
||||
if (!produkt) {
|
||||
throw new AgentApiFehler(422, "produkt_unbekannt", "Unbekanntes Produkt.");
|
||||
}
|
||||
|
||||
const vorhanden = await prisma.host.findUnique({ where: { agentId: eingabe.agentId } });
|
||||
const fremderHostMitServerId = eingabe.serverId
|
||||
? await prisma.host.findFirst({
|
||||
where: { serverId: eingabe.serverId, NOT: { agentId: eingabe.agentId } },
|
||||
})
|
||||
: null;
|
||||
|
||||
const neuerToken = erzeugeToken("host");
|
||||
|
||||
const hostId = await prisma.$transaction(async (tx) => {
|
||||
const host = vorhanden
|
||||
? await tx.host.update({
|
||||
where: { id: vorhanden.id },
|
||||
data: {
|
||||
hostname: eingabe.hostname,
|
||||
fqdn: eingabe.fqdn ?? vorhanden.fqdn,
|
||||
produkt,
|
||||
agentVersion: eingabe.agentVersion ?? vorhanden.agentVersion,
|
||||
// Eine Kundenzuordnung aus dem Enrollment-Token nur ergänzen, nie überschreiben.
|
||||
kundeId: vorhanden.kundeId ?? enrollToken.kundeId,
|
||||
standortId: vorhanden.standortId ?? enrollToken.standortId,
|
||||
},
|
||||
})
|
||||
: await tx.host.create({
|
||||
data: {
|
||||
agentId: eingabe.agentId,
|
||||
hostname: eingabe.hostname,
|
||||
fqdn: eingabe.fqdn ?? null,
|
||||
produkt,
|
||||
serverId: eingabe.serverId ?? null,
|
||||
agentVersion: eingabe.agentVersion ?? null,
|
||||
kundeId: enrollToken.kundeId,
|
||||
standortId: enrollToken.standortId,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.agentToken.create({
|
||||
data: {
|
||||
hostId: host.id,
|
||||
tokenHash: neuerToken.hash,
|
||||
praefix: neuerToken.praefix,
|
||||
bezeichnung: `Registrierung ${eingabe.hostname}`,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.enrollmentToken.update({
|
||||
where: { id: enrollToken.id },
|
||||
data: { nutzungen: { increment: 1 } },
|
||||
});
|
||||
|
||||
return host.id;
|
||||
});
|
||||
|
||||
return {
|
||||
hostId,
|
||||
token: neuerToken.klartext,
|
||||
serverIdBereitsBekannt: fremderHostMitServerId !== null,
|
||||
bestehenderHost: vorhanden !== null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Prüft den Host-Token und liefert den zugehörigen Host. */
|
||||
export async function authentifiziereHost(tokenKlartext: string): Promise<HostModel> {
|
||||
const eintrag = await prisma.agentToken.findUnique({
|
||||
where: { tokenHash: hashToken(tokenKlartext) },
|
||||
include: { host: true },
|
||||
});
|
||||
|
||||
if (!eintrag || eintrag.widerrufenAm) {
|
||||
throw new AgentApiFehler(401, "token_ungueltig", "Host-Token ist ungültig oder widerrufen.");
|
||||
}
|
||||
|
||||
await prisma.agentToken.update({
|
||||
where: { id: eintrag.id },
|
||||
data: { letzteNutzungAm: new Date() },
|
||||
});
|
||||
|
||||
return eintrag.host;
|
||||
}
|
||||
|
||||
export interface ReportErgebnis {
|
||||
reportId: string;
|
||||
/** Wahr, wenn derselbe Report bereits verarbeitet wurde (nachgesendeter Report). */
|
||||
doppelt: boolean;
|
||||
laufzeitVorschlagAngelegt: boolean;
|
||||
}
|
||||
|
||||
/** Nimmt einen Report entgegen, aktualisiert den Host und schlägt bei Bedarf eine neue Laufzeit vor. */
|
||||
export async function verarbeiteReport(
|
||||
host: HostModel,
|
||||
eingabe: ReportEingabe,
|
||||
quellIp: string | null,
|
||||
): Promise<ReportErgebnis> {
|
||||
const bereitsVorhanden = await prisma.report.findUnique({ where: { reportId: eingabe.reportId } });
|
||||
if (bereitsVorhanden) {
|
||||
return { reportId: bereitsVorhanden.id, doppelt: true, laufzeitVorschlagAngelegt: false };
|
||||
}
|
||||
|
||||
const erhobenAm = new Date(eingabe.erhobenAm);
|
||||
const status = statusAusProxmox(eingabe.subscription.status);
|
||||
const nextduedate = datumAusProxmox(eingabe.subscription.nextduedate);
|
||||
const regdate = datumAusProxmox(eingabe.subscription.regdate);
|
||||
const checktime = datumAusProxmox(eingabe.subscription.checktime);
|
||||
const key = eingabe.subscription.key ?? null;
|
||||
const serverId = eingabe.subscription.serverid ?? null;
|
||||
const produkt = produktAusEingabe(eingabe.produkt.typ) ?? host.produkt;
|
||||
|
||||
// Nachgesendete ältere Reports werden gespeichert, dürfen den aktuellen Stand aber nicht überschreiben.
|
||||
const istNeuesterStand = !host.letzterReportAm || erhobenAm.getTime() >= host.letzterReportAm.getTime();
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const report = await tx.report.create({
|
||||
data: {
|
||||
reportId: eingabe.reportId,
|
||||
hostId: host.id,
|
||||
erhobenAm,
|
||||
subStatus: status,
|
||||
subKey: key,
|
||||
subNextduedate: nextduedate,
|
||||
serverId,
|
||||
hostname: eingabe.host.hostname ?? null,
|
||||
produktVersion: eingabe.produkt.version ?? null,
|
||||
kernel: eingabe.host.kernel ?? null,
|
||||
sockets: eingabe.host.sockets ?? null,
|
||||
paketUpdates: eingabe.host.paketUpdates ?? null,
|
||||
agentVersion: eingabe.agentVersion ?? null,
|
||||
ips: eingabe.host.ips ?? [],
|
||||
quellIp,
|
||||
rohdaten: eingabe,
|
||||
},
|
||||
});
|
||||
|
||||
if (istNeuesterStand) {
|
||||
await tx.host.update({
|
||||
where: { id: host.id },
|
||||
data: {
|
||||
hostname: eingabe.host.hostname ?? host.hostname,
|
||||
fqdn: eingabe.host.fqdn ?? host.fqdn,
|
||||
produkt,
|
||||
produktVersion: eingabe.produkt.version ?? host.produktVersion,
|
||||
kernel: eingabe.host.kernel ?? host.kernel,
|
||||
serverId: serverId ?? host.serverId,
|
||||
clusterName: eingabe.host.clusterName ?? host.clusterName,
|
||||
nodeName: eingabe.host.nodeName ?? host.nodeName,
|
||||
ips: eingabe.host.ips ?? host.ips,
|
||||
sockets: eingabe.host.sockets ?? host.sockets,
|
||||
cpuModell: eingabe.host.cpuModell ?? host.cpuModell,
|
||||
uptimeSekunden:
|
||||
eingabe.host.uptimeSekunden === null || eingabe.host.uptimeSekunden === undefined
|
||||
? host.uptimeSekunden
|
||||
: BigInt(eingabe.host.uptimeSekunden),
|
||||
paketUpdates: eingabe.host.paketUpdates ?? host.paketUpdates,
|
||||
agentVersion: eingabe.agentVersion ?? host.agentVersion,
|
||||
subKey: key,
|
||||
subStatus: status,
|
||||
subLevel: eingabe.subscription.level ?? null,
|
||||
subProduktname: eingabe.subscription.productname ?? null,
|
||||
subLizenzierteSockets: eingabe.subscription.sockets ?? null,
|
||||
subRegdate: regdate,
|
||||
subNextduedate: nextduedate,
|
||||
subChecktime: checktime,
|
||||
letzterReportAm: erhobenAm,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (key) {
|
||||
await tx.subscriptionKeyHistorie.upsert({
|
||||
where: { hostId_key: { hostId: host.id, key } },
|
||||
update: { letzteMeldung: erhobenAm },
|
||||
create: { hostId: host.id, key, ersteMeldung: erhobenAm, letzteMeldung: erhobenAm },
|
||||
});
|
||||
}
|
||||
|
||||
if (serverId) {
|
||||
await tx.serverIdHistorie.upsert({
|
||||
where: { hostId_serverId: { hostId: host.id, serverId } },
|
||||
update: { letzteMeldung: erhobenAm },
|
||||
create: { hostId: host.id, serverId, ersteMeldung: erhobenAm, letzteMeldung: erhobenAm },
|
||||
});
|
||||
}
|
||||
|
||||
let laufzeitVorschlagAngelegt = false;
|
||||
if (key && nextduedate && istNeuesterStand) {
|
||||
const vorhanden = await tx.laufzeit.findUnique({
|
||||
where: { hostId_start: { hostId: host.id, start: nextduedate } },
|
||||
});
|
||||
|
||||
if (!vorhanden) {
|
||||
await tx.laufzeit.create({
|
||||
data: {
|
||||
hostId: host.id,
|
||||
key,
|
||||
start: nextduedate,
|
||||
ende: laufzeitEnde(nextduedate),
|
||||
status: "VORSCHLAG",
|
||||
},
|
||||
});
|
||||
laufzeitVorschlagAngelegt = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { reportId: report.id, doppelt: false, laufzeitVorschlagAngelegt };
|
||||
});
|
||||
}
|
||||
|
||||
/** Eine Subscription-Periode läuft ein Jahr, das Enddatum ist der Tag davor. */
|
||||
export function laufzeitEnde(start: Date): Date {
|
||||
const ende = new Date(start);
|
||||
ende.setUTCFullYear(ende.getUTCFullYear() + 1);
|
||||
ende.setUTCDate(ende.getUTCDate() - 1);
|
||||
return ende;
|
||||
}
|
||||
Reference in New Issue
Block a user