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,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