Merge branch 'phase/004-agent-api'
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user