diff --git a/CHANGELOG.md b/CHANGELOG.md index 55846fd..d5bc506 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ## [Unveröffentlicht] +### Hinzugefügt + +### Geändert + +### Behoben + +### Entfernt + +### Sicherheit + +## [0.1.0] – 2026-09-17 + ### Hinzugefügt - Projektorganisation: Devlog, Changelog, Issue-Übersicht und Issues pro Phase (#001) - Architektur- und Umsetzungsplan in `docs/architektur.md` (#001) @@ -17,6 +29,9 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). - Datenmodell mit Prisma: Kunden, Standorte, Hosts, Reports, Key- und Server-ID-Historie, Laufzeiten, Proxmox- und Kundenrechnungen mit Positionen, Tokens, Warnungen, Benachrichtigungen, Benutzer und Audit-Log (#003) - Erste Migration, Seed für Administrator, Warnregeln und Demo-Daten (#003) - Docker-Compose für die Entwicklungsdatenbank und Testdatenbank-Setup für Vitest (#003) +- Agent-API unter `/api/agent/v1`: Registrierung, Report und Verbindungstest (#004) +- Automatischer Laufzeit-Vorschlag, sobald ein Agent ein neues Ablaufdatum meldet (#004) +- Key- und Server-ID-Historie je Host (#004) ### Geändert @@ -25,3 +40,7 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Entfernt ### Sicherheit + +### Sicherheit +- API-Tokens werden nur als SHA-256-Hash gespeichert und sind einzeln widerrufbar (#004) +- Rate-Limiting und strikte zod-Validierung auf der Agent-API (#004) diff --git a/DEVLOG.md b/DEVLOG.md index 95a5787..43453bc 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,23 +2,24 @@ ## Zeitübersicht -**Gesamt:** 0 h 53 min +**Gesamt:** 1 h 13 min | Issue | Zeit | |---|---| | #001 Projekt-Setup | 0 h 08 min | | #002 Klickbarer Prototyp | 0 h 20 min | | #003 Datenmodell und Migrationen | 0 h 25 min | +| #004 Agent-API | 0 h 20 min | | Meilenstein | Zeit | |---|---| -| v0.1.0 | 0 h 53 min | +| v0.1.0 | 1 h 13 min | **abgeschlossen** --- ## 2026-09-17 · Sitzung 1 · 19:36–laufend -**Issue:** #001 Projekt-Setup, #002 Klickbarer Prototyp, #003 Datenmodell -**Version:** 0.1.0-dev +**Issue:** #001–#004 (Meilenstein v0.1.0) +**Version:** 0.1.0 ### Erledigt - Auftrag gelesen, Rückfragen gestellt @@ -29,6 +30,8 @@ - #002 abgeschlossen: Prototyp mit Beispieldaten – Layout (Sidebar, Kopfleiste, Inhaltskopf, Footer), Logo als SVG-Komponente, Theme mit Farbvorgaben und Dark-Mode, Dashboard, Kunden (Liste und Detail), Hosts (Liste, Detail, nicht zugeordnet), Rechnungen, Jahresbahn, Einstellungsseiten, globale Suche mit Strg+K - Screenshots aller Seiten aufgenommen und in `docs/anleitung/bilder/` abgelegt - #003 abgeschlossen: Prisma-Schema mit 23 Modellen, erste Migration, Seed (Administrator, 10 Warnregeln, Demo-Daten), Testdatenbank-Setup für Vitest, 3 Schema-Tests +- #004 abgeschlossen: Agent-API mit Enrollment, Report und Ping, gehashte Tokens, Rate-Limit, zod-Validierung, 32 Tests insgesamt +- Meilenstein v0.1.0 abgeschlossen und getaggt ### Entscheidungen - **Screenshots mit Firefox headless** statt Playwright: Firefox ist vorhanden, es muss kein Browser samt Systembibliotheken nachinstalliert werden (dafür wäre root nötig). @@ -41,6 +44,9 @@ - **Prisma 7.10.0 fest gesetzt:** Der npm-Tag `latest` zeigt derzeit auf 8.0.0-rc, ein Release Candidate gehört nicht in ein neues Projekt. - **Seed nutzt die Prototyp-Beispieldaten**, damit die Oberfläche ab #006 dieselben Fälle zeigt wie der Prototyp. - **Keine Nur-Lese-Rolle** (Rückmeldung Florian): alle Benutzer sind Administratoren. +- **Prisma-Client wird lazy erzeugt** (Proxy), damit `next build` ohne gesetzte `DATABASE_URL` durchläuft. +- **Report-Idempotenz über eine vom Agent erzeugte `reportId`**: nachgesendete Reports aus dem Spool dürfen beliebig oft ankommen. +- **Unbekannte Subscription-Status werden zu `UNKNOWN`** statt abgelehnt – ein Report darf nie an einem neuen Statuswort scheitern. ### Probleme / offene Fragen - Docker wurde von Florian installiert; die Entwicklungsdatenbank läuft auf Port 5433 (5432 bleibt für lokale Instanzen frei). @@ -48,4 +54,4 @@ - Offen: Rolle „readonly“ zusätzlich zu Admin? (vorläufig vorgesehen) ### Nächster Schritt -- #004 Agent-API: Enrollment, Report-Endpunkt, Token-Hashing, Rate-Limit, Tests +- #005 Agent-Skript (Bash) mit systemd-Timer, Spool und Installer diff --git a/docs/issues/004-agent-api.md b/docs/issues/004-agent-api.md index 3b08a2a..4d3b384 100644 --- a/docs/issues/004-agent-api.md +++ b/docs/issues/004-agent-api.md @@ -1,28 +1,33 @@ # #004 Agent-API -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.1.0 **Labels:** backend, api, sicherheit -**Geschätzt:** 6 h **Tatsächlich:** – +**Geschätzt:** 6 h **Tatsächlich:** 0 h 20 min ## Ziel Getrennte, abgesicherte API unter `/api/agent/v1`, über die sich Agents registrieren und Reports abliefern. ## Aufgaben -- [ ] Token-Bibliothek: Erzeugung (Präfix `plx_e_`/`plx_h_`), SHA-256-Hash, Prüfung, Widerruf, `lastUsedAt` -- [ ] `POST /enroll`: Enrollment-Token prüfen (Ablauf, Nutzungen), Host anlegen oder Neuinstallation erkennen, Host-Token ausgeben, optionale Kunden-/Standort-Zuordnung -- [ ] `POST /report`: zod-Schema (alle Felder nullable), idempotent über `reportId`, Transaktion (Report, Stammdaten, Key-Historie, Server-ID-Historie, Laufzeitvorschlag) -- [ ] `GET /ping` -- [ ] Rate-Limiting (pro IP und Token), `429` mit `Retry-After` -- [ ] Einheitliche Fehlerantworten (JSON), kein Stacktrace nach außen -- [ ] Tests: Auth (fehlend/falsch/widerrufen/abgelaufen), Validierung, Idempotenz, Key-Wechsel, Server-ID-Wechsel, Laufzeitvorschlag, Rate-Limit +- [x] Token-Bibliothek: Erzeugung (Präfix `plx_e_`/`plx_h_`), SHA-256-Hash, Prüfung, Widerruf, `lastUsedAt` +- [x] `POST /enroll`: Enrollment-Token prüfen (Ablauf, Nutzungen), Host anlegen oder Neuinstallation erkennen, Host-Token ausgeben, optionale Kunden-/Standort-Zuordnung +- [x] `POST /report`: zod-Schema (alle Felder nullable), idempotent über `reportId`, Transaktion (Report, Stammdaten, Key-Historie, Server-ID-Historie, Laufzeitvorschlag) +- [x] `GET /ping` +- [x] Rate-Limiting (pro IP und Token), `429` mit `Retry-After` +- [x] Einheitliche Fehlerantworten (JSON), kein Stacktrace nach außen +- [x] Tests: Auth (fehlend/falsch/widerrufen/abgelaufen), Validierung, Idempotenz, Key-Wechsel, Server-ID-Wechsel, Laufzeitvorschlag, Rate-Limit ## Akzeptanzkriterien -- [ ] Report mit ausschließlich `null`-Feldern (außer Pflicht-Metadaten) wird angenommen -- [ ] Tokens liegen nur gehasht in der DB -- [ ] UI-Session-Cookies werden von der Agent-API ignoriert -- [ ] Alle Tests grün +- [x] Report mit ausschließlich `null`-Feldern (außer Pflicht-Metadaten) wird angenommen +- [x] Tokens liegen nur gehasht in der DB +- [x] UI-Session-Cookies werden von der Agent-API ignoriert +- [x] Alle Tests grün ## Abhängigkeiten #003 ## Notizen / Entscheidungen +- Endpunkte: `POST /api/agent/v1/enroll`, `POST /api/agent/v1/report`, `GET /api/agent/v1/ping`. +- Antworten enthalten `agentVersion`, damit der Agent ein Update erkennen kann (#005). +- Nachgesendete ältere Reports werden gespeichert, überschreiben den aktuellen Hoststand aber nicht. +- Ein Report mit neuem `nextduedate` legt automatisch eine Laufzeit im Status VORSCHLAG an. +- Die Auslieferung von `install.sh` und `agent.sh` gehört zu #005. diff --git a/docs/issues/README.md b/docs/issues/README.md index 05c78ee..38df475 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -7,8 +7,8 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a | [#001](001-projekt-setup.md) | Projekt-Setup | erledigt | v0.1.0 | 3 h | 0 h 08 min | | [#002](002-prototyp.md) | Klickbarer Prototyp | erledigt | v0.1.0 | 8 h | 0 h 20 min | | [#003](003-datenmodell.md) | Datenmodell und Migrationen | erledigt | v0.1.0 | 5 h | 0 h 25 min | -| [#004](004-agent-api.md) | Agent-API | in Arbeit | v0.1.0 | 6 h | – | -| [#005](005-agent-skript.md) | Agent-Skript | offen | v0.2.0 | 8 h | – | +| [#004](004-agent-api.md) | Agent-API | erledigt | v0.1.0 | 6 h | 0 h 20 min | +| [#005](005-agent-skript.md) | Agent-Skript | in Arbeit | v0.2.0 | 8 h | – | | [#006](006-kunden-hostzuweisung.md) | Kunden, Standorte und Hostzuweisung | offen | v0.3.0 | 6 h | – | | [#007](007-hosts-detailansichten.md) | Host-Tabelle, Suche und Detailansichten | offen | v0.4.0 | 8 h | – | | [#008](008-laufzeiten-rechnungen.md) | Laufzeiten und Rechnungen | offen | v0.5.0 | 10 h | – | @@ -20,4 +20,4 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a | [#014](014-deployment.md) | Deployment und Dokumentation | offen | v0.10.0 | 8 h | – | | [#015](015-feinschliff-v1.md) | Feinschliff für 1.0 | offen | v1.0.0 | 6 h | – | | [#016](016-anleitung-screenshots.md) | Benutzerhandbuch mit Screenshots | offen | v0.10.0 | 5 h | – | -| | **Summe** | | | **106 h** | **0 h 53 min** | +| | **Summe** | | | **106 h** | **1 h 13 min** | diff --git a/package.json b/package.json index e6818b8..3c9591b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proxledger", - "version": "0.1.0-dev", + "version": "0.1.0", "private": true, "scripts": { "dev": "next dev", diff --git a/src/app/api/agent/v1/enroll/route.ts b/src/app/api/agent/v1/enroll/route.ts new file mode 100644 index 0000000..f1a74b9 --- /dev/null +++ b/src/app/api/agent/v1/enroll/route.ts @@ -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); + } +} diff --git a/src/app/api/agent/v1/ping/route.ts b/src/app/api/agent/v1/ping/route.ts new file mode 100644 index 0000000..376d3b5 --- /dev/null +++ b/src/app/api/agent/v1/ping/route.ts @@ -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); + } +} diff --git a/src/app/api/agent/v1/report/route.ts b/src/app/api/agent/v1/report/route.ts new file mode 100644 index 0000000..fb03d24 --- /dev/null +++ b/src/app/api/agent/v1/report/route.ts @@ -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); + } +} diff --git a/src/lib/db.ts b/src/lib/db.ts index 030873b..49a6b73 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -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); + }, +}); diff --git a/src/lib/ratelimit.ts b/src/lib/ratelimit.ts new file mode 100644 index 0000000..576bb06 --- /dev/null +++ b/src/lib/ratelimit.ts @@ -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(); + + 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"; +} diff --git a/src/lib/tokens.ts b/src/lib/tokens.ts new file mode 100644 index 0000000..94d34a6 --- /dev/null +++ b/src/lib/tokens.ts @@ -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; +} diff --git a/src/server/agent/http.ts b/src/server/agent/http.ts new file mode 100644 index 0000000..dfd066b --- /dev/null +++ b/src/server/agent/http.ts @@ -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( + request: Request, + schema: ZodType, +): 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 }; +} diff --git a/src/server/agent/schema.ts b/src/server/agent/schema.ts new file mode 100644 index 0000000..ab46477 --- /dev/null +++ b/src/server/agent/schema.ts @@ -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; +export type ReportEingabe = z.infer; + +const STATUS_ABBILDUNG: Record = { + 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; +} diff --git a/src/server/agent/service.ts b/src/server/agent/service.ts new file mode 100644 index 0000000..15becae --- /dev/null +++ b/src/server/agent/service.ts @@ -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 { + 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 { + 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 { + 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; +} diff --git a/tests/agent-api.test.ts b/tests/agent-api.test.ts new file mode 100644 index 0000000..e2192b6 --- /dev/null +++ b/tests/agent-api.test.ts @@ -0,0 +1,371 @@ +import { beforeEach, afterAll, describe, expect, it } from "vitest"; +import { agentRateLimit, enrollRateLimit } from "@/lib/ratelimit"; +import { erzeugeToken, hashToken } from "@/lib/tokens"; +import { POST as enrollPost } from "@/app/api/agent/v1/enroll/route"; +import { GET as pingGet } from "@/app/api/agent/v1/ping/route"; +import { POST as reportPost } from "@/app/api/agent/v1/report/route"; +import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; + +/** Legt einen Enrollment-Token an und gibt den Klartext zurück. */ +async function enrollmentTokenAnlegen( + optionen: { gueltigBis?: Date; maxNutzungen?: number; kundeId?: string; widerrufen?: boolean } = {}, +) { + const token = erzeugeToken("enrollment"); + await testPrisma.enrollmentToken.create({ + data: { + tokenHash: token.hash, + praefix: token.praefix, + bezeichnung: "Test", + gueltigBis: optionen.gueltigBis ?? null, + maxNutzungen: optionen.maxNutzungen ?? null, + kundeId: optionen.kundeId ?? null, + widerrufenAm: optionen.widerrufen ? new Date() : null, + }, + }); + return token.klartext; +} + +function anfrage(pfad: string, koerper: unknown, token?: string, methode = "POST") { + return new Request(`http://localhost:3000${pfad}`, { + method: methode, + headers: { + "content-type": "application/json", + ...(token ? { authorization: `Bearer ${token}` } : {}), + "x-forwarded-for": `10.0.0.${Math.ceil(Math.random() * 250)}`, + }, + body: methode === "GET" ? undefined : JSON.stringify(koerper), + }); +} + +const ENROLL_EINGABE = { + agentId: "machine-id-abcdef0123456789", + hostname: "pve-test-01", + fqdn: "pve-test-01.example.local", + produkt: "pve" as const, + serverId: "AABBCCDDEEFF0011", + agentVersion: "0.2.0", +}; + +/** Vollständiger Report, wie ihn der Agent auf einem PVE-Host erzeugt. */ +function reportEingabe(überschreibungen: Record = {}) { + return { + reportId: `report-${Math.random().toString(36).slice(2)}-0001`, + agentVersion: "0.2.0", + erhobenAm: "2026-09-17T03:41:00Z", + host: { + hostname: "pve-test-01", + fqdn: "pve-test-01.example.local", + ips: ["192.168.10.11", "10.20.0.11"], + clusterName: "test-cluster", + nodeName: "node01", + kernel: "6.8.12-4-pve", + uptimeSekunden: 4233600, + cpuModell: "Intel(R) Xeon(R) E-2388G", + sockets: 1, + paketUpdates: 3, + }, + produkt: { typ: "pve" as const, version: "pve-manager/8.3.2" }, + subscription: { + key: "pve1c-a6bd21fcb6", + status: "active", + level: "c", + productname: "Proxmox VE Community Subscription 1 CPU/year", + sockets: 1, + serverid: "AABBCCDDEEFF0011", + regdate: "2025-09-24", + nextduedate: "2026-09-24", + checktime: 1789610400, + }, + ...überschreibungen, + }; +} + +async function registriereTestHost() { + const enrollToken = await enrollmentTokenAnlegen(); + const antwort = await enrollPost(anfrage("/api/agent/v1/enroll", ENROLL_EINGABE, enrollToken)); + const daten = (await antwort.json()) as { hostId: string; token: string }; + return daten; +} + +describe.runIf(datenbankVerfuegbar)("Agent-API", () => { + beforeEach(async () => { + await datenbankLeeren(); + agentRateLimit.zuruecksetzen(); + enrollRateLimit.zuruecksetzen(); + }); + + afterAll(async () => { + await testPrisma.$disconnect(); + }); + + describe("Registrierung", () => { + it("legt einen Host an und gibt genau einmal einen Token aus", async () => { + const enrollToken = await enrollmentTokenAnlegen({ maxNutzungen: 3 }); + const antwort = await enrollPost(anfrage("/api/agent/v1/enroll", ENROLL_EINGABE, enrollToken)); + const daten = (await antwort.json()) as { ok: boolean; token: string; hostId: string }; + + expect(antwort.status).toBe(201); + expect(daten.token.startsWith("plx_h_")).toBe(true); + + const host = await testPrisma.host.findUnique({ + where: { id: daten.hostId }, + include: { tokens: true }, + }); + expect(host?.hostname).toBe("pve-test-01"); + expect(host?.produkt).toBe("PVE"); + + // In der Datenbank darf nur der Hash liegen. + expect(host?.tokens[0].tokenHash).toBe(hashToken(daten.token)); + expect(host?.tokens[0].tokenHash).not.toContain(daten.token); + + const verbraucht = await testPrisma.enrollmentToken.findFirst(); + expect(verbraucht?.nutzungen).toBe(1); + }); + + it("übernimmt die Kundenzuordnung aus dem Enrollment-Token", async () => { + const kunde = await testPrisma.kunde.create({ + data: { name: "Zuordnung GmbH", kundennummer: "K-8001" }, + }); + const enrollToken = await enrollmentTokenAnlegen({ kundeId: kunde.id }); + + const antwort = await enrollPost(anfrage("/api/agent/v1/enroll", ENROLL_EINGABE, enrollToken)); + const daten = (await antwort.json()) as { hostId: string }; + + const host = await testPrisma.host.findUnique({ where: { id: daten.hostId } }); + expect(host?.kundeId).toBe(kunde.id); + }); + + it("erkennt einen bereits bekannten Agent und legt keinen zweiten Host an", async () => { + const enrollToken = await enrollmentTokenAnlegen({ maxNutzungen: 5 }); + await enrollPost(anfrage("/api/agent/v1/enroll", ENROLL_EINGABE, enrollToken)); + const zweite = await enrollPost(anfrage("/api/agent/v1/enroll", ENROLL_EINGABE, enrollToken)); + const daten = (await zweite.json()) as { bestehenderHost: boolean }; + + expect(zweite.status).toBe(200); + expect(daten.bestehenderHost).toBe(true); + expect(await testPrisma.host.count()).toBe(1); + }); + + it("meldet eine bereits bekannte Server-ID (Neuinstallation oder Klon)", async () => { + const enrollToken = await enrollmentTokenAnlegen({ maxNutzungen: 5 }); + await enrollPost(anfrage("/api/agent/v1/enroll", ENROLL_EINGABE, enrollToken)); + + const antwort = await enrollPost( + anfrage( + "/api/agent/v1/enroll", + { ...ENROLL_EINGABE, agentId: "machine-id-9999999999999999", hostname: "pve-neu-01" }, + enrollToken, + ), + ); + const daten = (await antwort.json()) as { serverIdBereitsBekannt: boolean }; + expect(daten.serverIdBereitsBekannt).toBe(true); + }); + + it.each([ + ["fehlendem Token", async () => undefined, 401], + ["unbekanntem Token", async () => "plx_e_unbekannt", 401], + ["widerrufenem Token", async () => enrollmentTokenAnlegen({ widerrufen: true }), 401], + [ + "abgelaufenem Token", + async () => enrollmentTokenAnlegen({ gueltigBis: new Date("2020-01-01") }), + 401, + ], + ["aufgebrauchtem Token", async () => enrollmentTokenAnlegen({ maxNutzungen: 0 }), 401], + ])("weist Registrierung mit %s ab", async (_name, tokenErzeugen, status) => { + const token = await tokenErzeugen(); + const antwort = await enrollPost(anfrage("/api/agent/v1/enroll", ENROLL_EINGABE, token)); + expect(antwort.status).toBe(status); + expect(await testPrisma.host.count()).toBe(0); + }); + + it("lehnt unvollständige Eingaben mit 422 ab", async () => { + const enrollToken = await enrollmentTokenAnlegen(); + const antwort = await enrollPost( + anfrage("/api/agent/v1/enroll", { hostname: "ohne-agent-id" }, enrollToken), + ); + expect(antwort.status).toBe(422); + const daten = (await antwort.json()) as { code: string; felder: { feld: string }[] }; + expect(daten.code).toBe("validierung"); + expect(daten.felder.some((f) => f.feld === "agentId")).toBe(true); + }); + }); + + describe("Report", () => { + it("übernimmt einen vollständigen Report und schlägt eine Laufzeit vor", async () => { + const { hostId, token } = await registriereTestHost(); + + const antwort = await reportPost(anfrage("/api/agent/v1/report", reportEingabe(), token)); + const daten = (await antwort.json()) as { ok: boolean; laufzeitVorschlag: boolean }; + + expect(antwort.status).toBe(200); + expect(daten.laufzeitVorschlag).toBe(true); + + const host = await testPrisma.host.findUniqueOrThrow({ + where: { id: hostId }, + include: { keyHistorie: true, serverIdHistorie: true, laufzeiten: true, reports: true }, + }); + + expect(host.subStatus).toBe("ACTIVE"); + expect(host.subKey).toBe("pve1c-a6bd21fcb6"); + expect(host.subNextduedate?.toISOString().slice(0, 10)).toBe("2026-09-24"); + expect(host.ips).toEqual(["192.168.10.11", "10.20.0.11"]); + expect(host.uptimeSekunden).toBe(BigInt(4233600)); + expect(host.keyHistorie).toHaveLength(1); + expect(host.serverIdHistorie).toHaveLength(1); + expect(host.reports).toHaveLength(1); + + const laufzeit = host.laufzeiten[0]; + expect(laufzeit.status).toBe("VORSCHLAG"); + expect(laufzeit.start.toISOString().slice(0, 10)).toBe("2026-09-24"); + expect(laufzeit.ende.toISOString().slice(0, 10)).toBe("2027-09-23"); + }); + + it("nimmt einen Report an, in dem alle optionalen Felder fehlen", async () => { + const { hostId, token } = await registriereTestHost(); + + const antwort = await reportPost( + anfrage( + "/api/agent/v1/report", + { reportId: "nur-pflichtfelder-0001", erhobenAm: "2026-09-17T03:41:00Z" }, + token, + ), + ); + + expect(antwort.status).toBe(200); + const host = await testPrisma.host.findUniqueOrThrow({ where: { id: hostId } }); + expect(host.subStatus).toBe("UNKNOWN"); + expect(host.subKey).toBeNull(); + expect(host.hostname).toBe("pve-test-01"); + }); + + it("nimmt Reports mit null-Werten an und behandelt unbekannte Status als UNKNOWN", async () => { + const { hostId, token } = await registriereTestHost(); + + await reportPost( + anfrage( + "/api/agent/v1/report", + reportEingabe({ + subscription: { + key: null, + status: "irgendwas-neues", + level: null, + productname: null, + sockets: null, + serverid: null, + regdate: null, + nextduedate: null, + checktime: null, + }, + }), + token, + ), + ); + + const host = await testPrisma.host.findUniqueOrThrow({ where: { id: hostId } }); + expect(host.subStatus).toBe("UNKNOWN"); + expect(host.subNextduedate).toBeNull(); + }); + + it("verarbeitet denselben Report nur einmal", async () => { + const { hostId, token } = await registriereTestHost(); + const eingabe = reportEingabe(); + + await reportPost(anfrage("/api/agent/v1/report", eingabe, token)); + const zweite = await reportPost(anfrage("/api/agent/v1/report", eingabe, token)); + const daten = (await zweite.json()) as { doppelt: boolean }; + + expect(daten.doppelt).toBe(true); + expect(await testPrisma.report.count({ where: { hostId } })).toBe(1); + expect(await testPrisma.laufzeit.count({ where: { hostId } })).toBe(1); + }); + + it("lässt einen nachgesendeten älteren Report den aktuellen Stand nicht überschreiben", async () => { + const { hostId, token } = await registriereTestHost(); + + await reportPost(anfrage("/api/agent/v1/report", reportEingabe(), token)); + await reportPost( + anfrage( + "/api/agent/v1/report", + reportEingabe({ + reportId: "alter-report-0001", + erhobenAm: "2026-09-10T03:41:00Z", + subscription: { ...reportEingabe().subscription, status: "expired" }, + }), + token, + ), + ); + + const host = await testPrisma.host.findUniqueOrThrow({ where: { id: hostId } }); + expect(host.subStatus).toBe("ACTIVE"); + expect(await testPrisma.report.count({ where: { hostId } })).toBe(2); + }); + + it("merkt einen Key-Wechsel in der Historie vor", async () => { + const { hostId, token } = await registriereTestHost(); + + await reportPost(anfrage("/api/agent/v1/report", reportEingabe(), token)); + await reportPost( + anfrage( + "/api/agent/v1/report", + reportEingabe({ + reportId: "neuer-key-0001", + erhobenAm: "2026-09-18T03:41:00Z", + subscription: { ...reportEingabe().subscription, key: "pve1c-neuerkey99" }, + }), + token, + ), + ); + + const historie = await testPrisma.subscriptionKeyHistorie.findMany({ where: { hostId } }); + expect(historie.map((e) => e.key).sort()).toEqual(["pve1c-a6bd21fcb6", "pve1c-neuerkey99"]); + }); + + it("weist einen widerrufenen Host-Token ab", async () => { + const { token } = await registriereTestHost(); + await testPrisma.agentToken.updateMany({ data: { widerrufenAm: new Date() } }); + + const antwort = await reportPost(anfrage("/api/agent/v1/report", reportEingabe(), token)); + expect(antwort.status).toBe(401); + expect(await testPrisma.report.count()).toBe(0); + }); + + it("weist einen Report ohne Token ab", async () => { + const antwort = await reportPost(anfrage("/api/agent/v1/report", reportEingabe())); + expect(antwort.status).toBe(401); + }); + + it("lehnt einen Report ohne reportId mit 422 ab", async () => { + const { token } = await registriereTestHost(); + const antwort = await reportPost( + anfrage("/api/agent/v1/report", { erhobenAm: "2026-09-17T03:41:00Z" }, token), + ); + expect(antwort.status).toBe(422); + }); + }); + + describe("Ping und Rate-Limit", () => { + it("bestätigt einen gültigen Host-Token", async () => { + const { token, hostId } = await registriereTestHost(); + const antwort = await pingGet(anfrage("/api/agent/v1/ping", null, token, "GET")); + const daten = (await antwort.json()) as { ok: boolean; hostId: string; hostname: string }; + + expect(daten.ok).toBe(true); + expect(daten.hostId).toBe(hostId); + expect(daten.hostname).toBe("pve-test-01"); + }); + + it("bremst zu viele Reports mit 429 und Retry-After", async () => { + const { token } = await registriereTestHost(); + + let letzte: Response | undefined; + for (let i = 0; i < 12; i++) { + letzte = await reportPost( + anfrage("/api/agent/v1/report", reportEingabe({ reportId: `flut-${i}-0001` }), token), + ); + } + + expect(letzte?.status).toBe(429); + expect(Number(letzte?.headers.get("Retry-After"))).toBeGreaterThan(0); + }); + }); +}); diff --git a/tests/agent-hilfsfunktionen.test.ts b/tests/agent-hilfsfunktionen.test.ts new file mode 100644 index 0000000..3b8d0d1 --- /dev/null +++ b/tests/agent-hilfsfunktionen.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { RateLimit } from "@/lib/ratelimit"; +import { erzeugeToken, hashGleich, hashToken, tokenAusHeader } from "@/lib/tokens"; +import { datumAusProxmox, statusAusProxmox } from "@/server/agent/schema"; + +describe("Tokens", () => { + it("erzeugt eindeutige Tokens mit erkennbarem Präfix", () => { + const a = erzeugeToken("host"); + const b = erzeugeToken("host"); + + expect(a.klartext.startsWith("plx_h_")).toBe(true); + expect(erzeugeToken("enrollment").klartext.startsWith("plx_e_")).toBe(true); + expect(a.klartext).not.toBe(b.klartext); + expect(a.praefix.length).toBe("plx_h_".length + 6); + }); + + it("hasht deterministisch und vergleicht ohne Zeitunterschiede", () => { + const token = erzeugeToken("host"); + expect(hashToken(token.klartext)).toBe(token.hash); + expect(hashToken(` ${token.klartext} `)).toBe(token.hash); + expect(hashGleich(token.hash, token.hash)).toBe(true); + expect(hashGleich(token.hash, hashToken("etwas anderes"))).toBe(false); + }); + + it("liest den Bearer-Token aus dem Header", () => { + expect(tokenAusHeader("Bearer plx_h_abc")).toBe("plx_h_abc"); + expect(tokenAusHeader("bearer plx_h_abc ")).toBe("plx_h_abc"); + expect(tokenAusHeader("Basic abc")).toBeNull(); + expect(tokenAusHeader(null)).toBeNull(); + }); +}); + +describe("Datums- und Statusumwandlung", () => { + it("versteht die Formate von Proxmox", () => { + expect(datumAusProxmox("2026-09-24")?.toISOString()).toBe("2026-09-24T00:00:00.000Z"); + expect(datumAusProxmox(1789610400)?.toISOString()).toBe("2026-09-17T02:00:00.000Z"); + expect(datumAusProxmox("1789610400")?.toISOString()).toBe("2026-09-17T02:00:00.000Z"); + expect(datumAusProxmox("2026-09-17T03:41:00Z")?.toISOString()).toBe("2026-09-17T03:41:00.000Z"); + }); + + it("liefert null statt eines Fehlers bei unbrauchbaren Werten", () => { + expect(datumAusProxmox(null)).toBeNull(); + expect(datumAusProxmox("")).toBeNull(); + expect(datumAusProxmox("kein datum")).toBeNull(); + expect(datumAusProxmox(undefined)).toBeNull(); + }); + + it("bildet Proxmox-Status ab und fällt sonst auf UNKNOWN zurück", () => { + expect(statusAusProxmox("active")).toBe("ACTIVE"); + expect(statusAusProxmox("NotFound")).toBe("NOTFOUND"); + expect(statusAusProxmox("expired")).toBe("EXPIRED"); + expect(statusAusProxmox("etwas neues")).toBe("UNKNOWN"); + expect(statusAusProxmox(null)).toBe("UNKNOWN"); + }); +}); + +describe("Rate-Limit", () => { + it("lässt die Kapazität zu und bremst danach", () => { + const limit = new RateLimit(3, 1); + const jetzt = Date.now(); + + expect(limit.pruefe("a", jetzt).erlaubt).toBe(true); + expect(limit.pruefe("a", jetzt).erlaubt).toBe(true); + expect(limit.pruefe("a", jetzt).erlaubt).toBe(true); + + const abgelehnt = limit.pruefe("a", jetzt); + expect(abgelehnt.erlaubt).toBe(false); + expect(abgelehnt.wiederholenNach).toBeGreaterThan(0); + + // Anderer Schlüssel hat einen eigenen Eimer. + expect(limit.pruefe("b", jetzt).erlaubt).toBe(true); + // Nach einer Sekunde ist wieder ein Token verfügbar. + expect(limit.pruefe("a", jetzt + 1000).erlaubt).toBe(true); + }); +}); diff --git a/tests/setup-env.ts b/tests/setup-env.ts new file mode 100644 index 0000000..9764cf9 --- /dev/null +++ b/tests/setup-env.ts @@ -0,0 +1,6 @@ +import "dotenv/config"; + +// Tests laufen immer gegen die Testdatenbank, nie gegen die Entwicklungsdatenbank. +if (process.env.TEST_DATABASE_URL) { + process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; +} diff --git a/tsconfig.json b/tsconfig.json index cf9c65d..7d553a8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "ES2017", + "target": "ES2022", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, diff --git a/vitest.config.mts b/vitest.config.mts index 1e11b17..51a91a6 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -9,6 +9,7 @@ export default defineConfig({ include: ["tests/**/*.test.ts", "src/**/*.test.ts"], environment: "node", globalSetup: ["tests/global-setup.ts"], + setupFiles: ["tests/setup-env.ts"], // Datenbanktests teilen sich eine Datenbank und dürfen sich nicht überholen. fileParallelism: false, },