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
+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;
}