Merge branch 'phase/004-agent-api'

This commit is contained in:
2026-09-17 20:39:33 +02:00
19 changed files with 1209 additions and 30 deletions
+19
View File
@@ -7,6 +7,18 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht] ## [Unveröffentlicht]
### Hinzugefügt
### Geändert
### Behoben
### Entfernt
### Sicherheit
## [0.1.0] – 2026-09-17
### Hinzugefügt ### Hinzugefügt
- Projektorganisation: Devlog, Changelog, Issue-Übersicht und Issues pro Phase (#001) - Projektorganisation: Devlog, Changelog, Issue-Übersicht und Issues pro Phase (#001)
- Architektur- und Umsetzungsplan in `docs/architektur.md` (#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) - 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) - Erste Migration, Seed für Administrator, Warnregeln und Demo-Daten (#003)
- Docker-Compose für die Entwicklungsdatenbank und Testdatenbank-Setup für Vitest (#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 ### Geändert
@@ -25,3 +40,7 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Entfernt ### Entfernt
### Sicherheit ### 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)
+11 -5
View File
@@ -2,23 +2,24 @@
## Zeitübersicht ## Zeitübersicht
**Gesamt:** 0 h 53 min **Gesamt:** 1 h 13 min
| Issue | Zeit | | Issue | Zeit |
|---|---| |---|---|
| #001 Projekt-Setup | 0 h 08 min | | #001 Projekt-Setup | 0 h 08 min |
| #002 Klickbarer Prototyp | 0 h 20 min | | #002 Klickbarer Prototyp | 0 h 20 min |
| #003 Datenmodell und Migrationen | 0 h 25 min | | #003 Datenmodell und Migrationen | 0 h 25 min |
| #004 Agent-API | 0 h 20 min |
| Meilenstein | Zeit | | 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 ## 2026-09-17 · Sitzung 1 · 19:36–laufend
**Issue:** #001 Projekt-Setup, #002 Klickbarer Prototyp, #003 Datenmodell **Issue:** #001–#004 (Meilenstein v0.1.0)
**Version:** 0.1.0-dev **Version:** 0.1.0
### Erledigt ### Erledigt
- Auftrag gelesen, Rückfragen gestellt - 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 - #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 - 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 - #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 ### 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). - **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. - **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. - **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. - **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 ### Probleme / offene Fragen
- Docker wurde von Florian installiert; die Entwicklungsdatenbank läuft auf Port 5433 (5432 bleibt für lokale Instanzen frei). - 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) - Offen: Rolle „readonly“ zusätzlich zu Admin? (vorläufig vorgesehen)
### Nächster Schritt ### Nächster Schritt
- #004 Agent-API: Enrollment, Report-Endpunkt, Token-Hashing, Rate-Limit, Tests - #005 Agent-Skript (Bash) mit systemd-Timer, Spool und Installer
+18 -13
View File
@@ -1,28 +1,33 @@
# #004 Agent-API # #004 Agent-API
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.1.0 **Meilenstein:** v0.1.0
**Labels:** backend, api, sicherheit **Labels:** backend, api, sicherheit
**Geschätzt:** 6 h **Tatsächlich:** – **Geschätzt:** 6 h **Tatsächlich:** 0 h 20 min
## Ziel ## Ziel
Getrennte, abgesicherte API unter `/api/agent/v1`, über die sich Agents registrieren und Reports abliefern. Getrennte, abgesicherte API unter `/api/agent/v1`, über die sich Agents registrieren und Reports abliefern.
## Aufgaben ## Aufgaben
- [ ] Token-Bibliothek: Erzeugung (Präfix `plx_e_`/`plx_h_`), SHA-256-Hash, Prüfung, Widerruf, `lastUsedAt` - [x] 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 - [x] `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) - [x] `POST /report`: zod-Schema (alle Felder nullable), idempotent über `reportId`, Transaktion (Report, Stammdaten, Key-Historie, Server-ID-Historie, Laufzeitvorschlag)
- [ ] `GET /ping` - [x] `GET /ping`
- [ ] Rate-Limiting (pro IP und Token), `429` mit `Retry-After` - [x] Rate-Limiting (pro IP und Token), `429` mit `Retry-After`
- [ ] Einheitliche Fehlerantworten (JSON), kein Stacktrace nach außen - [x] 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] Tests: Auth (fehlend/falsch/widerrufen/abgelaufen), Validierung, Idempotenz, Key-Wechsel, Server-ID-Wechsel, Laufzeitvorschlag, Rate-Limit
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Report mit ausschließlich `null`-Feldern (außer Pflicht-Metadaten) wird angenommen - [x] Report mit ausschließlich `null`-Feldern (außer Pflicht-Metadaten) wird angenommen
- [ ] Tokens liegen nur gehasht in der DB - [x] Tokens liegen nur gehasht in der DB
- [ ] UI-Session-Cookies werden von der Agent-API ignoriert - [x] UI-Session-Cookies werden von der Agent-API ignoriert
- [ ] Alle Tests grün - [x] Alle Tests grün
## Abhängigkeiten ## Abhängigkeiten
#003 #003
## Notizen / Entscheidungen ## 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.
+3 -3
View File
@@ -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 | | [#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 | | [#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 | | [#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 | – | | [#004](004-agent-api.md) | Agent-API | erledigt | v0.1.0 | 6 h | 0 h 20 min |
| [#005](005-agent-skript.md) | Agent-Skript | offen | v0.2.0 | 8 h | – | | [#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 | – | | [#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 | – | | [#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 | – | | [#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 | – | | [#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 | – | | [#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 | – | | [#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** |
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxledger", "name": "proxledger",
"version": "0.1.0-dev", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
+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"; import { PrismaClient } from "@/generated/prisma/client";
/** /**
* Prisma-Client als Singleton. Im Entwicklungsmodus lädt Next.js Module neu, * Prisma-Client als Singleton, der erst beim ersten Zugriff verbindet.
* ohne den Prozess zu beenden – ohne Singleton entstünden dabei immer neue Verbindungspools. * 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 { declare global {
var proxledgerPrisma: PrismaClient | undefined; var proxledgerPrisma: PrismaClient | undefined;
@@ -14,11 +16,17 @@ function erzeugeClient(): PrismaClient {
if (!connectionString) { if (!connectionString) {
throw new Error("DATABASE_URL ist nicht gesetzt. Beispielwerte stehen in .env.example."); 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(); export function getPrisma(): PrismaClient {
return globalThis.proxledgerPrisma ?? erzeugeClient();
if (process.env.NODE_ENV !== "production") {
globalThis.proxledgerPrisma = prisma;
} }
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;
}
+371
View File
@@ -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<string, unknown> = {}) {
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);
});
});
});
+75
View File
@@ -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);
});
});
+6
View File
@@ -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;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2017", "target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"], "lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
+1
View File
@@ -9,6 +9,7 @@ export default defineConfig({
include: ["tests/**/*.test.ts", "src/**/*.test.ts"], include: ["tests/**/*.test.ts", "src/**/*.test.ts"],
environment: "node", environment: "node",
globalSetup: ["tests/global-setup.ts"], globalSetup: ["tests/global-setup.ts"],
setupFiles: ["tests/setup-env.ts"],
// Datenbanktests teilen sich eine Datenbank und dürfen sich nicht überholen. // Datenbanktests teilen sich eine Datenbank und dürfen sich nicht überholen.
fileParallelism: false, fileParallelism: false,
}, },