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
+45
View File
@@ -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);
}
}
+35
View File
@@ -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);
}
}
+44
View File
@@ -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
View File
@@ -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);
},
});
+69
View File
@@ -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";
}
+48
View File
@@ -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;
}
+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;
}