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:
2026-09-17 20:39:33 +02:00
co-authored by Claude Opus 5
parent 51c8aafa69
commit bef51eb863
19 changed files with 1209 additions and 30 deletions
+69
View File
@@ -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 };
}
+112
View File
@@ -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;
}
+266
View File
@@ -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;
}