3 Commits
Author SHA1 Message Date
florian.wildeandClaude Opus 5.5 a9a48dccfa chore(release): 0.1.0 – Server-Verwaltung und Verbindungstest
Etappe 1 abgeschlossen und auf dem Testhost pve-demo (PVE 9.2.2)
abgenommen: Rolle und Token nach docs/proxmox-rechte.md angelegt,
Fingerprint und SSH-Host-Key gegen den Node verglichen, Verbindungstest
mit und ohne SSH.

Der Fingerprint-Hinweis nennt jetzt ausdrücklich pve-ssl.pem bzw.
pveproxy-ssl.pem – pvenode cert info listet zuerst die Root-CA.

Closes #003

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:01:54 +02:00
florian.wildeandClaude Opus 5.5 d64b8fb24d test: Hinweis auf die Rechte-Doku statt README erwarten
Der Hinweis zeigt seit dem vorigen Commit auf docs/proxmox-rechte.md;
der Test erwartete noch das README.

Refs #003

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:45:14 +02:00
florian.wildeandClaude Opus 5.5 86b650e20a feat(server): Verbindungstest mit Fähigkeitserkennung
Der Test liest Version, Cluster, Nodes, Storages, Bridges, VNets und die
effektiven Rechte des Tokens und leitet daraus ab, welcher Vorgang per
API, per SSH oder gar nicht geht – je PVE-Version: Template-Bau per API
erst ab 8.4 mit import-Storage, Snippets und LXC-Exec nur per SSH.

Fehlende Rechte erscheinen einzeln mit Pfad und Zweck; die Rollenliste
in docs/proxmox-rechte.md prüft ein Test gegen den Code. SSH-Host-Keys
werden wie Zertifikate erst nach Bestätigung genutzt, und bestätigt
werden kann nur ein Schlüssel, den der letzte Test gesehen hat.

Statussymbole in der GUI sind jetzt gezeichnet: ◐ und ○ fehlen im
Web-Subset von JetBrains Mono.

Refs #003

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:44:27 +02:00
33 changed files with 2140 additions and 42 deletions
+15 -6
View File
@@ -7,6 +7,18 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht]
### Hinzugefügt
### Geändert
### Behoben
### Entfernt
### Sicherheit
## [0.1.0] – 2026-10-12
### Hinzugefügt
- Architektur- und Umsetzungsplan (`docs/architektur.md`) mit Prüfung der Proxmox-API 8.x/9.x und der Image-Quellen (#001)
- Issues #001–#020 für die sechs Etappen und den Feldtest (#001)
@@ -17,14 +29,11 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
- Server-Verwaltung: Proxmox-Server anlegen, bearbeiten, löschen; Zertifikat ansehen und Fingerprint pinnen; Verbindung vor dem Speichern prüfen (#002)
- Optionales SSH-Schlüsselpaar je Server mit Einzeiler zum Hinterlegen auf den Nodes (#002)
- Server-Umschalter in der Seitenleiste, Serverliste auf der Übersicht (#002)
- Verbindungstest: Version, Cluster, Nodes, Storages, Bridges, VNets und die Rechte des Tokens; je Vorgang, ob er per API, per SSH oder gar nicht geht (#003)
- SSH zu den Nodes mit Host-Key-Pinning nach Bestätigung (#003)
- `docs/proxmox-rechte.md`: Rolle `ProxDeploy` für PVE 8 und 9 mit fertigen `pveum`-Befehlen (#003)
- Proxmox-Client mit verständlichen Fehlern (Ursache + Hinweis, fehlendes Recht samt Pfad) und Warten auf Proxmox-Tasks (#002)
### Geändert
### Behoben
### Entfernt
### Sicherheit
- Geheimnisse werden mit AES-256-GCM verschlüsselt, Schlüssel aus `PROXDEPLOY_SCHLUESSEL` (#001)
- Passwörter mit argon2id; Sperre nach zehn Fehlversuchen in 15 Minuten; Content-Security-Policy mit Nonce (#001)
+7 -2
View File
@@ -2,7 +2,7 @@
## Zeitübersicht
**Gesamt:** 1 h 01 min (laufende Sitzung)
**Gesamt:** 1 h 15 min (laufende Sitzung)
Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeldungen ist enthalten.
@@ -10,10 +10,11 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
|---|---|
| #001 Projekt-Setup | 0 h 50 min |
| #002 Server-Verwaltung | 0 h 11 min |
| #003 Verbindungstest | 0 h 25 min |
| Meilenstein | Zeit |
|---|---|
| v0.1.0 | – |
| v0.1.0 | 1 h 15 min | **abgeschlossen**
---
@@ -28,6 +29,9 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
- Image-URLs, Prüfsummen-Dateien, LXC-Template-Namen, Docker- und Zabbix-Repos per Abruf geprüft
- Git-Repository (`main`), CHANGELOG, DEVLOG, Architekturplan und 20 Issues angelegt
- Plan freigegeben: Ubuntu 26.04 dazu, Templates mit UEFI, Template-VMID = nächste freie im Template-Bereich, Namensabbildung bestätigt
- #003 abgeschlossen: Verbindungstest mit Fähigkeitserkennung und SSH-Host-Key-Pinning; 127 Tests
- Feldtest auf dem Testhost `pve-demo` (PVE 9.2.2): Rolle/Token nach Doku per SSH angelegt, Fingerprint und Host-Key gegen den Node verglichen, Test mit und ohne SSH – alles wie erwartet
- Meilenstein v0.1.0 abgeschlossen, getaggt und nach Gitea gepusht
- #002 abgeschlossen: Server-Verwaltung mit Fingerprint-Pinning, Proxmox-Client, Proxmox-Mock (HTTPS, selbst signiert); 99 Tests, 10 Playwright-Abläufe
- Testhost 10.0.10.228 von Florian bereitgestellt (Installation läuft)
- #001 abgeschlossen: Grundgerüst mit Anmeldung, Seitenleiste, beiden Themes; 60 Tests, 8 Playwright-Abläufe (Desktop + 390 px)
@@ -43,4 +47,5 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
- **Sitzungsgeheimnis und Datenschlüssel getrennt** (`SITZUNG_GEHEIMNIS`, `PROXDEPLOY_SCHLUESSEL`): ein Tausch des Sitzungsgeheimnisses meldet nur alle ab, der Datenschlüssel darf sich nie ändern.
- **Amber und Rot im hellen Theme abgedunkelt** (`#8A5A00`, `#B91C1C`): die Originaltöne erreichen auf `#F4F6F8` keine 4,5:1.
- **TLS-Pinning über einen eigenen undici-Connector** statt `rejectUnauthorized: false` + Nachprüfen: die Verbindung wird getrennt, bevor das Token gesendet wird. Sitzungs-Cache aus, sonst fehlt bei Wiederaufnahme das Zertifikat.
- **Zugang zum Testhost über einen eigenen SSH-Schlüssel** (`~/.ssh/proxdeploy-testhost`), nicht über das root-Passwort: Claude Code verweigert das Anlegen von Zugängen mit Passwort, und ein Schlüssel nur für diesen Host ist ohnehin der sauberere Weg.
- **Deutsche Bezeichner** wie in ProxLedger und Laufzettel; YAML-Schlüssel im Katalog bleiben englisch (Vorgabe).
+17 -10
View File
@@ -1,25 +1,32 @@
# #003 Verbindungstest und Fähigkeitserkennung
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.1.0
**Labels:** feature, proxmox
**Geschätzt:** 6 h **Tatsächlich:** –
**Geschätzt:** 6 h **Tatsächlich:** 0 h 25 min
## Ziel
Button „Verbindung testen“ zeigt Version, Cluster/Nodes, Storages, Bridges/VLANs und was per API bzw. nur per SSH geht.
## Aufgaben
- [ ] `/version`, `/cluster/status`, `/cluster/resources`, `/nodes/{n}/storage`, `/nodes/{n}/network`, SDN-VNets
- [ ] Rechte über `/access/permissions` gegen Rolle (8.x/9.x-Variante) abgleichen, fehlende einzeln nennen
- [ ] Fähigkeiten: download-url import (≥ 8.4), import-from per Volume, Snippets-Storage, SSH erreichbar
- [ ] Übersichtsseite mit Serverstatus und freien Ressourcen; Serverdetail mit Nodes/Storages/Bridges
- [ ] Proxmox-Mock für Tests (8.3, 8.4, 9.2 als Varianten)
- [x] `/version`, `/cluster/status`, `/nodes`, `/nodes/{n}/version|storage|network`, SDN-VNets
- [x] Rechte über `/access/permissions?path=…` gegen Rolle (8.x/9.x-Variante) abgleichen, fehlende einzeln nennen
- [x] Fähigkeiten: download-url import (≥ 8.4), import-from per Volume, Snippets-Storage, SSH erreichbar
- [x] SSH je Node mit Host-Key-Pinning (erst erkunden, dann bestätigen)
- [x] Übersichtsseite mit Serverstatus; Serverdetail mit Nodes/Storages/Bridges/Rechten/Fähigkeiten
- [x] Proxmox-Mock für Tests (8.3, 8.4, 9.2, Cluster) und SSH-Mock
- [x] `docs/proxmox-rechte.md` mit `pveum`-Befehlen; Test hält die Liste mit dem Code gleich
## Akzeptanzkriterien
- [ ] Gegen echten Proxmox geprüft (sobald Testumgebung da ist)
- [ ] Tests decken 8.3 (SSH nötig), 8.4 und 9.2 ab
- [x] Gegen echten Proxmox geprüft: Testhost `pve-demo` (10.0.10.228), PVE 9.2.2, am 2026-10-12
- [x] Tests decken 8.3 (SSH nötig), 8.4 und 9.2 ab
## Abhängigkeiten
#002
## Notizen / Entscheidungen
–
- **Feldtest am 2026-10-12 auf PVE 9.2.2:** Rolle, Benutzer und Token genau nach `docs/proxmox-rechte.md` angelegt; alle Pflichtrechte erkannt, nur die vier optionalen fehlen. Fingerprint aus ProxDeploy = `pve-ssl.pem` laut `pvenode cert info`; SSH-Host-Key = `ssh-keygen -lf` auf dem Node. Mit SSH: Snippets, `pct exec` und erweiterte LXC-Features per SSH, Template-Bau per API.
- **`pvenode cert info` listet zuerst die Root-CA** – der Hinweis im Formular nennt deshalb ausdrücklich `pveproxy-ssl.pem` bzw. `pve-ssl.pem`.
- **Ein Host-Key lässt sich nur bestätigen, wenn der letzte Test ihn gesehen hat** – über die API kann niemand einen beliebigen Schlüssel unterschieben.
- **Fehlende Leserechte sind Warnungen, kein Abbruch:** der Bericht soll gerade bei unvollständiger Rolle helfen.
- **Statussymbole gezeichnet:** ◐ und ○ fehlen im Web-Subset von JetBrains Mono.
- Feld `Server.faehigkeiten` heißt jetzt `letzterTest` (Migration per RENAME, keine Daten verloren).
+1 -1
View File
@@ -6,7 +6,7 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
|---|---|---|---|---|---|
| [#001](001-projekt-setup.md) | Projekt-Setup | erledigt | v0.1.0 | 4 h | 0 h 50 min |
| [#002](002-verschluesselung-server.md) | Server-Verwaltung und Verschlüsselung | erledigt | v0.1.0 | 6 h | 0 h 11 min |
| [#003](003-verbindungstest.md) | Verbindungstest und Fähigkeitserkennung | offen | v0.1.0 | 6 h | – |
| [#003](003-verbindungstest.md) | Verbindungstest und Fähigkeitserkennung | erledigt | v0.1.0 | 6 h | 0 h 25 min |
| [#004](004-katalog.md) | Katalog: Distributionen und Profile als Daten | offen | v0.2.0 | 5 h | – |
| [#005](005-adapter-profile.md) | Adapter debian und Profil-Engine | offen | v0.2.0 | 6 h | – |
| [#006](006-auftraege-sse.md) | Aufträge, Phasen, worker und Live-Fortschritt | offen | v0.2.0 | 8 h | – |
+94
View File
@@ -0,0 +1,94 @@
# Proxmox-Rechte für das API-Token
ProxDeploy braucht auf jedem Proxmox-Server ein API-Token. Es sollte **nicht** `root@pam` gehören,
sondern einem eigenen Benutzer mit einer eigenen Rolle `ProxDeploy`, die nur das Nötige erlaubt.
Der Verbindungstest in ProxDeploy vergleicht die tatsächlichen Rechte mit dieser Liste und nennt
fehlende Rechte einzeln, mit Pfad und Zweck.
## Rolle anlegen (PVE 9.x)
Auf einem Node des Clusters als root:
```bash
pveum role add ProxDeploy --privs "VM.Allocate VM.Clone VM.Audit VM.Config.Disk VM.Config.CDROM VM.Config.CPU VM.Config.Memory VM.Config.Network VM.Config.HWType VM.Config.Options VM.Config.Cloudinit VM.PowerMgmt VM.GuestAgent.Audit VM.GuestAgent.Unrestricted Datastore.AllocateSpace Datastore.AllocateTemplate Datastore.Audit SDN.Use Sys.Audit Sys.AccessNetwork"
```
## Rolle anlegen (PVE 8.x)
```bash
pveum role add ProxDeploy --privs "VM.Allocate VM.Clone VM.Audit VM.Config.Disk VM.Config.CDROM VM.Config.CPU VM.Config.Memory VM.Config.Network VM.Config.HWType VM.Config.Options VM.Config.Cloudinit VM.PowerMgmt VM.Monitor Datastore.AllocateSpace Datastore.AllocateTemplate Datastore.Audit SDN.Use Sys.Audit Sys.AccessNetwork"
```
Vor PVE 8.2 gibt es `Sys.AccessNetwork` noch nicht; dort statt dessen `Sys.Modify` eintragen.
Nach einem Upgrade von 8 auf 9 die Rolle anpassen: `VM.Monitor` gibt es in PVE 9 nicht mehr, der
Guest-Agent hat eigene Rechte bekommen.
```bash
pveum role modify ProxDeploy --privs "…Liste für PVE 9.x…"
```
## Benutzer, Rechte und Token
```bash
pveum user add proxdeploy@pve --comment "ProxDeploy"
pveum acl modify / --users proxdeploy@pve --roles ProxDeploy
pveum user token add proxdeploy@pve deploy --privsep 0
```
Die letzte Zeile gibt das Secret **einmal** aus – zusammen mit der Token-ID
`proxdeploy@pve!deploy` in ProxDeploy unter *Server → Server anlegen* eintragen.
`--privsep 0` lässt das Token die Rechte seines Benutzers erben. Mit Privilege Separation
(`--privsep 1`, Standard in der Oberfläche) braucht das Token die Rolle selbst:
`pveum acl modify / --tokens 'proxdeploy@pve!deploy' --roles ProxDeploy`.
## Enger gefasst
Statt der Rolle auf `/` genügen diese Pfade:
| Pfad | Wofür |
|---|---|
| `/vms` (oder ein Pool `/pool/<name>`) | VMs und Container anlegen, konfigurieren, starten, Guest-Agent |
| `/storage/<id>` je Storage für Gäste und Images | Disks anlegen, Images und LXC-Templates laden |
| `/sdn/zones/localnetwork` (bzw. die genutzten SDN-Zonen) | Bridges und VNets an Gäste hängen |
| `/nodes/<node>` | `Sys.AccessNetwork` für `download-url` |
| `/` | `Sys.Audit` – Cluster-Status und Rechte lesen |
## Wozu welches Recht
| Recht | Wofür |
|---|---|
| `VM.Allocate` | VMs und Container anlegen und (beim Aufräumen) löschen |
| `VM.Clone` | Templates klonen |
| `VM.Audit` | Konfiguration lesen |
| `VM.Config.Disk` | Disks anlegen, importieren, vergrößern |
| `VM.Config.CDROM` | cloud-init-Laufwerk |
| `VM.Config.CPU`, `VM.Config.Memory` | Ressourcen |
| `VM.Config.Network` | Netzwerkkarten |
| `VM.Config.HWType` | Maschinentyp q35, UEFI, SCSI-Controller |
| `VM.Config.Options` | Name, Tags, Guest-Agent, Start bei Boot |
| `VM.Config.Cloudinit` | Benutzer, SSH-Keys, IP-Konfiguration |
| `VM.PowerMgmt` | Starten und Stoppen |
| `VM.GuestAgent.Audit` (9.x) | IP-Adressen über den Guest-Agent |
| `VM.GuestAgent.Unrestricted` (9.x) | Provisionierung über den Guest-Agent (`exec`, `file-write`) |
| `VM.Monitor` (8.x) | Guest-Agent in PVE 8 |
| `Datastore.AllocateSpace` | Disks auf dem Storage |
| `Datastore.AllocateTemplate` | Cloud-Images und LXC-Templates herunterladen |
| `Datastore.Audit` | Storage-Inhalt lesen |
| `SDN.Use` | Bridges und VNets nutzen |
| `Sys.Audit` | Cluster-Status, Rechte |
| `Sys.AccessNetwork` (ab 8.2) | `download-url` |
**Optional:** `Datastore.Allocate` (heruntergeladene Images beim Aufräumen löschen), `SDN.Audit`
(VNets anzeigen), `VM.Console` (Konsole), `Sys.Modify` (privilegierte Container ab PVE 9).
## Was ein Token grundsätzlich nicht kann
Diese Dinge erlaubt Proxmox nur `root@pam` mit Passwort-Anmeldung, nie einem Token. ProxDeploy
erledigt sie deshalb per SSH, wenn SSH für den Server eingerichtet ist:
- Container-Features außer `nesting` (`keyctl`, `fuse`, `mknod`)
- cloud-init-Snippets schreiben (es gibt dafür keine API)
- Befehle in Containern ausführen (`pct exec`; es gibt keine Exec-API für Container)
- Disk-Images von einem absoluten Pfad importieren (vor PVE 8.4 nötig)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "proxdeploy",
"version": "0.1.0-dev",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI",
@@ -0,0 +1,2 @@
-- Umbenennen statt neu anlegen: der Inhalt bleibt erhalten.
ALTER TABLE "Server" RENAME COLUMN "faehigkeiten" TO "letzterTest";
+2 -2
View File
@@ -92,8 +92,8 @@ model Server {
bauNetz Json?
/// Standard-Node, -Storage, -Bridge für den Assistenten.
standards Json?
/// Ergebnis der letzten Fähigkeitserkennung (#003).
faehigkeiten Json?
/// Bericht des letzten Verbindungstests inkl. Fähigkeiten (VerbindungsBericht, #003).
letzterTest Json?
erreichbar Boolean?
pveVersion String?
geprueftAm DateTime?
+10 -1
View File
@@ -11,6 +11,7 @@ import { seiteSchuetzen } from "@/server/auth/zugang";
import { serverSicht } from "@/server/services/server";
import { ServerStatus } from "../server-status";
import { ServerAktionen } from "./server-aktionen";
import { Verbindungsbericht } from "./verbindungsbericht";
export async function generateMetadata({ params }: { params: Promise<{ name: string }> }): Promise<Metadata> {
return { title: (await params).name };
@@ -67,6 +68,13 @@ export default async function ServerDetail({ params }: { params: Promise<{ name:
</dl>
</Karte>
<Verbindungsbericht
name={server.name}
bericht={server.letzterTest}
geprueftAm={server.geprueftAm}
verwalten={verwalten}
/>
<Karte titel="SSH zu den Nodes (optional)">
{!server.sshAktiv ? (
<Status art="offen">Nicht genutzt. Deployments laufen dann nur über die API.</Status>
@@ -80,7 +88,8 @@ export default async function ServerDetail({ params }: { params: Promise<{ name:
</p>
<Code>{`echo '${server.sshOeffentlich}' >> ~/.ssh/authorized_keys`}</Code>
<p className="text-muted">
Host-Keys werden beim ersten Verbindungstest angezeigt und nach Bestätigung gepinnt (#003).
Host-Keys erscheinen nach dem Verbindungstest beim jeweiligen Node und werden erst nach
Bestätigung genutzt.
</p>
</div>
) : (
@@ -0,0 +1,320 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Karte } from "@/components/karte";
import { Knopf } from "@/components/knopf";
import { Status, type StatusArt } from "@/components/status";
import type { VerbindungsBericht } from "@/engine/proxmox/erkundung";
import type { Strategie } from "@/engine/proxmox/faehigkeiten";
import { apiAufruf, ApiFehler } from "@/lib/api-client";
import { bytes, prozent, zeitpunkt } from "@/lib/formate";
type TestErgebnis =
{ ok: true; bericht: VerbindungsBericht } | { ok: false; art: string; ursache: string; hinweis: string };
const WEG_STATUS: Record<Strategie["weg"], { art: StatusArt; text: string }> = {
api: { art: "erledigt", text: "per API" },
ssh: { art: "laeuft", text: "per SSH" },
nicht_moeglich: { art: "fehler", text: "nicht möglich" },
};
const VORGAENGE: Array<[keyof VerbindungsBericht["faehigkeiten"], string]> = [
["templateBau", "Template bauen"],
["vmProvisionierung", "VM provisionieren"],
["lxcProvisionierung", "LXC provisionieren"],
["snippets", "cloud-init-Snippets"],
["lxcFeaturesErweitert", "LXC-Features außer nesting"],
];
/**
* Ergebnis des Verbindungstests: Version, Nodes, Storages, Bridges, fehlende Rechte und – am
* wichtigsten – welcher Vorgang auf welchem Weg geht. Host-Keys lassen sich hier bestätigen.
*/
export function Verbindungsbericht({
name,
bericht: anfangsBericht,
geprueftAm,
verwalten,
}: {
name: string;
bericht: VerbindungsBericht | null;
geprueftAm: string | null;
verwalten: boolean;
}) {
const router = useRouter();
const [bericht, setBericht] = useState(anfangsBericht);
const [fehler, setFehler] = useState<{ ursache: string; hinweis?: string } | null>(null);
const [laeuft, setLaeuft] = useState(false);
const [zeit, setZeit] = useState(geprueftAm);
async function testen() {
setLaeuft(true);
setFehler(null);
try {
const ergebnis = await apiAufruf<TestErgebnis>("POST", `/api/v1/server/${name}/test`);
setZeit(new Date().toISOString());
if (ergebnis.ok) setBericht(ergebnis.bericht);
else setFehler({ ursache: ergebnis.ursache, hinweis: ergebnis.hinweis });
router.refresh();
} catch (ursache) {
setFehler({ ursache: ursache instanceof ApiFehler ? ursache.message : "Unerwarteter Fehler." });
} finally {
setLaeuft(false);
}
}
async function bestaetigen(knoten: string, hostSchluessel: string) {
try {
await apiAufruf("POST", `/api/v1/server/${name}/ssh/hostschluessel`, { knoten, hostSchluessel });
await testen();
} catch (ursache) {
setFehler({
ursache: ursache instanceof ApiFehler ? ursache.message : "Unerwarteter Fehler.",
hinweis: ursache instanceof ApiFehler ? ursache.hinweis : undefined,
});
}
}
const pflichtFehlend = bericht?.fehlendeRechte.filter((r) => r.pflicht) ?? [];
const optionalFehlend = bericht?.fehlendeRechte.filter((r) => !r.pflicht) ?? [];
return (
<div className="flex flex-col gap-6">
<Karte
titel="Verbindungstest"
aktion={
<Knopf variante="sekundaer" onClick={testen} disabled={laeuft}>
{laeuft ? "Testet …" : "Verbindung testen"}
</Knopf>
}
>
<div aria-live="polite" className="flex flex-col gap-3 text-sm">
{laeuft ? (
<Status art="laeuft">Liest Version, Nodes, Storages, Netzwerk und Rechte …</Status>
) : null}
{fehler ? (
<div>
<Status art="fehler">{fehler.ursache}</Status>
{fehler.hinweis ? <p className="text-muted mt-1">{fehler.hinweis}</p> : null}
</div>
) : null}
{!bericht && !fehler && !laeuft ? <Status art="offen">Noch nicht getestet.</Status> : null}
{bericht ? (
<dl className="grid gap-x-6 gap-y-2 sm:grid-cols-[max-content_1fr]">
<dt className="text-muted">Proxmox VE</dt>
<dd className="font-mono">{bericht.version}</dd>
<dt className="text-muted">Cluster</dt>
<dd>
{bericht.cluster ? (
<>
<span className="font-mono">{bericht.cluster.name}</span> ·{" "}
{bericht.cluster.quorate ? "Quorum vorhanden" : "kein Quorum"}
</>
) : (
"Einzelhost"
)}
</dd>
<dt className="text-muted">Geprüft</dt>
<dd>{zeitpunkt(zeit)}</dd>
</dl>
) : null}
{bericht?.warnungen.map((warnung) => (
<Status key={warnung} art="laeuft">
{warnung}
</Status>
))}
</div>
</Karte>
{bericht ? (
<>
<Karte titel="Was geht auf diesem Server">
<ul className="flex flex-col gap-3 text-sm">
{VORGAENGE.map(([schluessel, titel]) => {
const strategie = bericht.faehigkeiten[schluessel];
const anzeige = WEG_STATUS[strategie.weg];
return (
<li key={schluessel} className="grid gap-1 sm:grid-cols-[14rem_8rem_1fr] sm:gap-3">
<span className="font-medium">{titel}</span>
<Status art={anzeige.art}>{anzeige.text}</Status>
<span className="text-muted">{strategie.grund}</span>
</li>
);
})}
</ul>
</Karte>
<Karte titel="Rechte des API-Tokens">
{pflichtFehlend.length === 0 ? (
<Status art="erledigt">Alle nötigen Rechte vorhanden.</Status>
) : (
<div className="flex flex-col gap-3 text-sm">
<Status art="fehler">
{pflichtFehlend.length === 1 ? "Ein Recht fehlt" : `${pflichtFehlend.length} Rechte fehlen`}{" "}
– die Rolle „ProxDeploy“ steht vollständig in docs/proxmox-rechte.md.
</Status>
<RechteListe rechte={pflichtFehlend} />
</div>
)}
{optionalFehlend.length > 0 ? (
<details className="mt-4 text-sm">
<summary className="text-muted cursor-pointer">
{optionalFehlend.length} optionale Rechte fehlen
</summary>
<div className="mt-2">
<RechteListe rechte={optionalFehlend} />
</div>
</details>
) : null}
</Karte>
{bericht.knoten.map((knoten) => (
<Karte
key={knoten.name}
titel={
<span className="font-mono">
{knoten.name}
{knoten.ip ? <span className="text-muted font-normal"> · {knoten.ip}</span> : null}
</span>
}
aktion={
knoten.online ? <Status art="erledigt">online</Status> : <Status art="fehler">offline</Status>
}
>
{knoten.online ? (
<div className="flex flex-col gap-5 text-sm">
<dl className="grid grid-cols-2 gap-x-6 gap-y-1 sm:grid-cols-4">
<dt className="text-muted">Version</dt>
<dd className="font-mono">{knoten.version ?? "–"}</dd>
<dt className="text-muted">CPU</dt>
<dd>
{knoten.cpus ?? "–"} Kerne · {prozent(knoten.cpuAuslastung)}
</dd>
<dt className="text-muted">RAM</dt>
<dd>
{bytes(knoten.speicherBelegt)} / {bytes(knoten.speicherGesamt)}
</dd>
</dl>
<div className="overflow-x-auto">
<table className="w-full min-w-[32rem] text-left">
<caption className="text-muted mb-2 text-left text-xs font-medium tracking-wide uppercase">
Storages
</caption>
<thead className="text-muted">
<tr>
<th className="py-1 pr-4 font-medium">Name</th>
<th className="py-1 pr-4 font-medium">Typ</th>
<th className="py-1 pr-4 font-medium">Inhalte</th>
<th className="py-1 font-medium">Frei</th>
</tr>
</thead>
<tbody>
{knoten.storages.map((s) => (
<tr key={s.storage} className="border-linie border-t">
<td className="py-1.5 pr-4 font-mono">{s.storage}</td>
<td className="py-1.5 pr-4">{s.typ}</td>
<td className="text-muted py-1.5 pr-4">{s.inhalte.join(", ")}</td>
<td className="py-1.5">
{bytes(s.frei)} / {bytes(s.gesamt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div>
<p className="text-muted mb-2 text-xs font-medium tracking-wide uppercase">Bridges</p>
<ul className="flex flex-wrap gap-2">
{knoten.bridges.map((b) => (
<li key={b.name} className="border-linie rounded-lg border px-3 py-1.5">
<span className="font-mono">{b.name}</span>
<span className="text-muted">
{b.vlanBewusst ? " · VLAN-fähig" : ""}
{b.cidr ? ` · ${b.cidr}` : ""}
{b.kommentar ? ` · ${b.kommentar}` : ""}
</span>
</li>
))}
</ul>
</div>
{knoten.ssh ? (
<div className="flex flex-col gap-2">
<p className="text-muted text-xs font-medium tracking-wide uppercase">SSH</p>
{knoten.ssh.zustand === "ok" ? (
<Status art="erledigt">Verbunden mit {knoten.ssh.host} · Host-Key bestätigt</Status>
) : knoten.ssh.zustand === "nicht_bestaetigt" ? (
<div className="flex flex-col gap-2">
<Status art="laeuft">
Host-Key noch nicht bestätigt – bitte mit dem Node vergleichen:
</Status>
<code className="bg-grund border-linie rounded-lg border px-3 py-2 font-mono text-xs break-all">
{knoten.ssh.hostSchluessel}
</code>
<p className="text-muted">
Auf dem Node:{" "}
<span className="font-mono">
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
</span>
</p>
{verwalten && knoten.ssh.hostSchluessel ? (
<Knopf
variante="sekundaer"
className="self-start"
onClick={() => bestaetigen(knoten.name, knoten.ssh!.hostSchluessel!)}
>
Host-Key von {knoten.name} bestätigen
</Knopf>
) : null}
</div>
) : (
<Status art="fehler">{knoten.ssh.meldung ?? "SSH nicht möglich."}</Status>
)}
</div>
) : null}
</div>
) : (
<p className="text-muted text-sm">Node ist offline – keine Details.</p>
)}
</Karte>
))}
{bericht.vnets && bericht.vnets.length > 0 ? (
<Karte titel="SDN-VNets">
<ul className="flex flex-wrap gap-2 text-sm">
{bericht.vnets.map((v) => (
<li key={v.name} className="border-linie rounded-lg border px-3 py-1.5">
<span className="font-mono">{v.name}</span>
<span className="text-muted">
{" "}
· Zone {v.zone}
{v.tag ? ` · VLAN ${v.tag}` : ""}
{v.alias ? ` · ${v.alias}` : ""}
</span>
</li>
))}
</ul>
</Karte>
) : null}
</>
) : null}
</div>
);
}
function RechteListe({ rechte }: { rechte: VerbindungsBericht["fehlendeRechte"] }) {
return (
<ul className="flex flex-col gap-1.5">
{rechte.map((r) => (
<li key={`${r.recht}${r.pfad}`} className="grid gap-x-3 sm:grid-cols-[16rem_10rem_1fr]">
<span className="font-mono">{r.recht}</span>
<span className="text-muted font-mono">{r.pfad}</span>
<span className="text-muted">{r.wofuer}</span>
</li>
))}
</ul>
);
}
+6 -5
View File
@@ -6,7 +6,7 @@ import { useState } from "react";
import { EINGABE_KLASSE, Feld, Schalter, beschreibung, Code } from "@/components/formular";
import { Karte } from "@/components/karte";
import { Knopf } from "@/components/knopf";
import { Status } from "@/components/status";
import { Status, StatusSymbol } from "@/components/status";
import { apiAufruf, ApiFehler } from "@/lib/api-client";
import type { ServerSicht } from "@/lib/schemas/server";
@@ -219,8 +219,9 @@ export function ServerFormular({ server }: { server?: ServerSicht }) {
<span className="text-muted text-sm">SHA-256-Fingerprint</span>
<Code>{zertifikat.fingerabdruck}</Code>
<p className="text-muted text-sm">
Zum Vergleichen auf dem Node: <span className="font-mono">pvenode cert info</span> oder in
der Proxmox-Oberfläche unter Node → System → Zertifikate.
Zum Vergleichen auf dem Node: <span className="font-mono">pvenode cert info</span> –
maßgeblich ist <span className="font-mono">pveproxy-ssl.pem</span>, falls vorhanden, sonst{" "}
<span className="font-mono">pve-ssl.pem</span> (nicht die Root-CA).
</p>
</div>
<Schalter
@@ -246,7 +247,7 @@ export function ServerFormular({ server }: { server?: ServerSicht }) {
{felder["fingerabdruck"] ? (
<p className="text-fehler text-sm">
<span aria-hidden="true">✗ </span>
<StatusSymbol art="fehler" className="mr-1 inline translate-y-[0.125em]" />
{felder["fingerabdruck"]}
</p>
) : null}
@@ -295,7 +296,7 @@ export function ServerFormular({ server }: { server?: ServerSicht }) {
</Feld>
</div>
<p className="text-muted text-sm">
Welche Rechte das Token braucht, steht im README unter „Proxmox-Rechte“.
Welche Rechte das Token braucht, steht in der Doku „Proxmox-Rechte“ (docs/proxmox-rechte.md).
</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api, jsonLesen } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { hostSchluesselBestaetigen } from "@/server/services/server";
type Kontext = { params: Promise<{ name: string }> };
export const POST = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "serverVerwalten");
return NextResponse.json(
await hostSchluesselBestaetigen(
(await params).name,
await jsonLesen(anfrage),
aufrufer,
aufruferAdresse(anfrage.headers),
),
);
});
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { serverTesten } from "@/server/services/server";
type Kontext = { params: Promise<{ name: string }> };
/** Verbindungstest: liest nur. Auch Operatoren dürfen ihn auslösen, um vor dem Ausrollen zu prüfen. */
export const POST = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "lesen");
return NextResponse.json(
await serverTesten((await params).name, aufrufer, aufruferAdresse(anfrage.headers)),
);
});
+2 -1
View File
@@ -1,4 +1,5 @@
import { clsx } from "clsx";
import { StatusSymbol } from "@/components/status";
/**
* Formular-Bausteine: echtes `<label>`, Fehlermeldung per `aria-describedby` verbunden,
@@ -30,7 +31,7 @@ export function Feld({
{children}
{fehler ? (
<p id={`${id}-fehler`} className="text-fehler text-sm">
<span aria-hidden="true">✗ </span>
<StatusSymbol art="fehler" className="mr-1 inline translate-y-[0.125em]" />
{fehler}
</p>
) : hilfe ? (
+31 -6
View File
@@ -1,11 +1,13 @@
import { clsx } from "clsx";
/**
* Status immer als Symbol + Farbe + Text, nie nur Farbe (Vorgabe Barrierearmut).
* Dieselben Symbole wie in der CLI-Ausgabe.
* Status immer als Symbol + Farbe + Text, nie nur Farbe (Vorgabe Barrierearmut). Die Symbole
* entsprechen ✓ ◐ ✗ ○ der CLI, sind hier aber gezeichnet: ◐ und ○ fehlen im Web-Subset von
* JetBrains Mono, der Browser fiele auf eine beliebige Schrift mit winzigem Glyph zurück.
*/
export type StatusArt = "erledigt" | "laeuft" | "fehler" | "offen";
/** Die Zeichen der CLI – für Textausgaben (Terminal-Karte, Kopiertext). */
export const STATUS_SYMBOL: Record<StatusArt, string> = {
erledigt: "✓",
laeuft: "◐",
@@ -20,6 +22,31 @@ const STATUS_FARBE: Record<StatusArt, string> = {
offen: "text-muted",
};
export function StatusSymbol({ art, className }: { art: StatusArt; className?: string }) {
return (
<svg
viewBox="0 0 16 16"
aria-hidden="true"
className={clsx("inline-block h-[1em] w-[1em] shrink-0", className)}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
{art === "erledigt" ? <path d="M3 8.5 6.5 12 13 4.5" /> : null}
{art === "fehler" ? <path d="M4 4 12 12M12 4 4 12" /> : null}
{art === "offen" ? <circle cx="8" cy="8" r="5.5" strokeWidth="1.6" /> : null}
{art === "laeuft" ? (
<>
<circle cx="8" cy="8" r="5.5" strokeWidth="1.6" />
<path d="M8 2.5a5.5 5.5 0 0 1 0 11Z" fill="currentColor" stroke="none" />
</>
) : null}
</svg>
);
}
export function Status({
art,
children,
@@ -30,10 +57,8 @@ export function Status({
className?: string;
}) {
return (
<span className={clsx("inline-flex items-center gap-1.5", STATUS_FARBE[art], className)}>
<span aria-hidden="true" className="font-mono">
{STATUS_SYMBOL[art]}
</span>
<span className={clsx("inline-flex items-baseline gap-1.5", STATUS_FARBE[art], className)}>
<StatusSymbol art={art} className="translate-y-[0.125em]" />
<span>{children}</span>
</span>
);
+319
View File
@@ -0,0 +1,319 @@
import { SshFehler, sshAusfuehren } from "@/engine/ssh/verbindung";
import { pfad, type ProxmoxClient } from "./client";
import {
faehigkeitenAbleiten,
pfadFuer,
rechtePruefen,
versionLesen,
type Faehigkeiten,
type FehlendesRecht,
type PveVersion,
type RechteJePfad,
type SshZustand,
type StorageInfo,
} from "./faehigkeiten";
import { ProxmoxFehler } from "./fehler";
/**
* Der Verbindungstest: liest alles, was ProxDeploy über einen Server wissen muss, und leitet die
* Fähigkeiten ab. Nur lesende Aufrufe. Fehlt für einen Teil ein Recht, wird das als Warnung
* vermerkt statt den ganzen Test abzubrechen – der Bericht soll gerade dann helfen.
*/
export type BridgeInfo = {
name: string;
typ: string;
vlanBewusst: boolean;
aktiv: boolean;
cidr: string | null;
kommentar: string | null;
};
export type SshKnoten = {
zustand: SshZustand;
host: string;
hostSchluessel: string | null;
bestaetigt: boolean;
meldung: string | null;
};
export type KnotenInfo = {
name: string;
online: boolean;
ip: string | null;
version: string | null;
cpuAuslastung: number | null;
cpus: number | null;
speicherBelegt: number | null;
speicherGesamt: number | null;
storages: StorageInfo[];
bridges: BridgeInfo[];
ssh: SshKnoten | null;
};
export type VnetInfo = { name: string; zone: string; tag: number | null; alias: string | null };
export type VerbindungsBericht = {
geprueftAm: string;
version: string;
release: string;
cluster: { name: string; quorate: boolean } | null;
knoten: KnotenInfo[];
vnets: VnetInfo[] | null;
fehlendeRechte: FehlendesRecht[];
ssh: SshZustand;
faehigkeiten: Faehigkeiten;
warnungen: string[];
};
export type SshZugang = {
benutzer: string;
port: number;
privatSchluessel: string | null;
hostSchluessel: Record<string, string>;
};
type ClusterStatusEintrag =
| { type: "cluster"; name: string; quorate: number; nodes: number }
| { type: "node"; name: string; online: number; ip?: string; local?: number };
type NodeEintrag = {
node: string;
status: string;
cpu?: number;
maxcpu?: number;
mem?: number;
maxmem?: number;
};
type StorageEintrag = {
storage: string;
type: string;
content: string;
active?: number;
enabled?: number;
shared?: number;
avail?: number;
total?: number;
};
type NetzEintrag = {
iface: string;
type: string;
active?: number;
bridge_vlan_aware?: number;
cidr?: string;
comments?: string;
};
async function versuchen<T>(warnungen: string[], text: string, aufruf: () => Promise<T>): Promise<T | null> {
try {
return await aufruf();
} catch (fehler) {
if (fehler instanceof ProxmoxFehler && (fehler.art === "recht" || fehler.art === "nicht_gefunden")) {
warnungen.push(`${text}: ${fehler.ursache}`);
return null;
}
throw fehler;
}
}
export async function erkunden(
client: ProxmoxClient,
optionen: { serverHost: string; ssh: SshZugang | null; jetzt?: () => Date },
): Promise<VerbindungsBericht> {
const warnungen: string[] = [];
const version = await client.get<{ version: string; release: string }>("/version");
const pveVersion: PveVersion = versionLesen(version.version) ?? {
major: 0,
minor: 0,
patch: 0,
text: version.version,
};
if (pveVersion.major < 8) {
warnungen.push(`PVE ${version.version} wird nicht unterstützt – ProxDeploy braucht 8.0 oder neuer.`);
}
// Cluster und Nodes. /cluster/status braucht Sys.Audit; ohne bleibt die Node-Liste.
const clusterStatus = await versuchen(warnungen, "Cluster-Status", () =>
client.get<ClusterStatusEintrag[]>("/cluster/status"),
);
const clusterEintrag = clusterStatus?.find((e) => e.type === "cluster");
const nodeIps = new Map(
(clusterStatus ?? [])
.filter((e): e is Extract<ClusterStatusEintrag, { type: "node" }> => e.type === "node")
.map((e) => [e.name, e.ip ?? null]),
);
const nodes = await client.get<NodeEintrag[]>("/nodes");
const knoten: KnotenInfo[] = [];
for (const node of [...nodes].sort((a, b) => a.node.localeCompare(b.node))) {
const online = node.status === "online";
let storages: StorageInfo[] = [];
let bridges: BridgeInfo[] = [];
let nodeVersion: string | null = null;
if (online) {
const v = await versuchen(warnungen, `Version von ${node.node}`, () =>
client.get<{ version: string }>(pfad`/nodes/${node.node}/version`),
);
nodeVersion = v?.version ?? null;
storages = (
(await versuchen(warnungen, `Storages von ${node.node}`, () =>
client.get<StorageEintrag[]>(pfad`/nodes/${node.node}/storage`),
)) ?? []
)
.filter((s) => s.enabled !== 0)
.map((s) => ({
storage: s.storage,
typ: s.type,
inhalte: s.content.split(",").filter(Boolean),
aktiv: s.active === 1,
geteilt: s.shared === 1,
frei: s.avail ?? null,
gesamt: s.total ?? null,
}))
.sort((a, b) => a.storage.localeCompare(b.storage));
bridges = (
(await versuchen(warnungen, `Netzwerk von ${node.node}`, () =>
client.get<NetzEintrag[]>(pfad`/nodes/${node.node}/network`),
)) ?? []
)
.filter((n) => n.type === "bridge" || n.type === "OVSBridge")
.map((n) => ({
name: n.iface,
typ: n.type,
vlanBewusst: n.bridge_vlan_aware === 1 || n.type === "OVSBridge",
aktiv: n.active === 1,
cidr: n.cidr ?? null,
kommentar: n.comments?.trim() || null,
}))
.sort((a, b) => a.name.localeCompare(b.name));
}
knoten.push({
name: node.node,
online,
ip: nodeIps.get(node.node) ?? null,
version: nodeVersion,
cpuAuslastung: node.cpu ?? null,
cpus: node.maxcpu ?? null,
speicherBelegt: node.mem ?? null,
speicherGesamt: node.maxmem ?? null,
storages,
bridges,
ssh: null,
});
}
const vnetsRoh = await versuchen(warnungen, "SDN-VNets", () =>
client.get<Array<{ vnet: string; zone: string; tag?: number; alias?: string }>>("/cluster/sdn/vnets"),
);
const vnets =
vnetsRoh?.map((v) => ({ name: v.vnet, zone: v.zone, tag: v.tag ?? null, alias: v.alias ?? null })) ??
null;
// Rechte: effektiv je Pfad. Storage-Rechte nur für Storages, die Gäste aufnehmen können.
const gastStorages = [
...new Set(
knoten.flatMap((k) =>
k.storages
.filter((s) => s.inhalte.includes("images") || s.inhalte.includes("rootdir"))
.map((s) => s.storage),
),
),
];
const onlineNodes = knoten.filter((k) => k.online).map((k) => k.name);
const pfade = [
"/",
"/vms",
pfadFuer("sdn", {}),
...onlineNodes.map((node) => pfadFuer("node", { node })),
...gastStorages.map((storage) => pfadFuer("storage", { storage })),
];
const rechte: RechteJePfad = {};
for (const p of pfade) {
const antwort = await versuchen(warnungen, `Rechte auf ${p}`, () =>
client.get<RechteJePfad>("/access/permissions", { path: p }),
);
rechte[p] = antwort?.[p] ?? {};
}
const fehlendeRechte = rechtePruefen(pveVersion, rechte, { storages: gastStorages, nodes: onlineNodes });
// SSH je Node: nur wenn eingerichtet. Ohne gepinnten Host-Key wird erkundet, nicht genutzt.
let sshGesamt: SshZustand = "aus";
if (optionen.ssh) {
if (!optionen.ssh.privatSchluessel) {
sshGesamt = "kein_schluessel";
} else {
const zustaende: SshZustand[] = [];
for (const k of knoten.filter((k) => k.online)) {
// Einzelhost ohne Cluster-Status: die Adresse aus der Server-URL.
const host = k.ip ?? (knoten.length === 1 ? optionen.serverHost : k.name);
const pin = optionen.ssh.hostSchluessel[k.name] ?? null;
try {
const ergebnis = await sshAusfuehren(
{
host,
port: optionen.ssh.port,
benutzer: optionen.ssh.benutzer,
privatSchluessel: optionen.ssh.privatSchluessel,
hostSchluessel: pin,
},
"pveversion",
{ erkunden: !pin, zeitlimitMs: 15_000 },
);
const zustand: SshZustand = pin ? "ok" : "nicht_bestaetigt";
k.ssh = {
zustand,
host,
hostSchluessel: ergebnis.hostSchluessel,
bestaetigt: Boolean(pin),
meldung: ergebnis.stdout.trim() || null,
};
zustaende.push(zustand);
} catch (fehler) {
if (!(fehler instanceof SshFehler)) throw fehler;
k.ssh = {
zustand: "fehler",
host,
hostSchluessel: fehler.hostSchluessel ?? null,
bestaetigt: Boolean(pin),
meldung: `${fehler.ursache} ${fehler.hinweis}`,
};
zustaende.push("fehler");
}
}
sshGesamt = zustaende.includes("fehler")
? "fehler"
: zustaende.includes("nicht_bestaetigt")
? "nicht_bestaetigt"
: zustaende.length > 0
? "ok"
: "fehler";
}
}
// Im gemischten Cluster zählt die älteste Version.
const versionen = knoten.map((k) => (k.version ? versionLesen(k.version) : null)).filter((v) => v !== null);
const aelteste = versionen.reduce<PveVersion>(
(alt, v) => (v.major < alt.major || (v.major === alt.major && v.minor < alt.minor) ? v : alt),
pveVersion,
);
const alleStorages = knoten.flatMap((k) => k.storages);
return {
geprueftAm: (optionen.jetzt?.() ?? new Date()).toISOString(),
version: version.version,
release: version.release,
cluster: clusterEintrag ? { name: clusterEintrag.name, quorate: clusterEintrag.quorate === 1 } : null,
knoten,
vnets,
fehlendeRechte,
ssh: sshGesamt,
faehigkeiten: faehigkeitenAbleiten({
version: aelteste,
storages: alleStorages,
ssh: sshGesamt,
rechteFehlen: fehlendeRechte.some((r) => r.pflicht),
}),
warnungen,
};
}
+287
View File
@@ -0,0 +1,287 @@
/**
* Was geht auf diesem Server per API, was nur per SSH, was gar nicht – als reine Funktion aus
* den erkundeten Daten. Grundlage ist die Prüfung der Proxmox-API vom 2026-10-12
* (docs/architektur.md, Abschnitt 3).
*/
export type PveVersion = { major: number; minor: number; patch: number; text: string };
export function versionLesen(text: string): PveVersion | null {
const treffer = /^(\d+)\.(\d+)(?:[.-](\d+))?/.exec(text.trim());
if (!treffer) return null;
return {
major: Number(treffer[1]),
minor: Number(treffer[2]),
patch: Number(treffer[3] ?? 0),
text: text.trim(),
};
}
export function mindestens(version: PveVersion, major: number, minor: number): boolean {
return version.major > major || (version.major === major && version.minor >= minor);
}
// ── Rechte ──────────────────────────────────────────────────────────────────────────────────────
export type RechtBereich = "vms" | "storage" | "sdn" | "node" | "wurzel";
export type RechtAnforderung = {
recht: string;
bereich: RechtBereich;
/** Wofür – erscheint in der Oberfläche neben einem fehlenden Recht. */
wofuer: string;
pflicht: boolean;
};
const VM_RECHTE: Array<[string, string]> = [
["VM.Allocate", "VMs und Container anlegen und löschen"],
["VM.Clone", "Templates klonen"],
["VM.Audit", "Konfiguration lesen"],
["VM.Config.Disk", "Disks anlegen, importieren, vergrößern"],
["VM.Config.CDROM", "cloud-init-Laufwerk"],
["VM.Config.CPU", "CPU einstellen"],
["VM.Config.Memory", "RAM einstellen"],
["VM.Config.Network", "Netzwerk einstellen"],
["VM.Config.HWType", "Maschinentyp, BIOS, SCSI-Controller"],
["VM.Config.Options", "Name, Tags, Agent, Start bei Boot"],
["VM.Config.Cloudinit", "cloud-init (Benutzer, Keys, IP)"],
["VM.PowerMgmt", "Starten und Stoppen"],
];
/**
* Die Rolle `ProxDeploy` je Hauptversion. PVE 9 hat `VM.Monitor` gestrichen und den Guest-Agent
* in eigene Rechte aufgeteilt; `Sys.AccessNetwork` (für download-url) gibt es seit 8.2.
*/
export function benoetigteRechte(version: PveVersion): RechtAnforderung[] {
const liste: RechtAnforderung[] = VM_RECHTE.map(([recht, wofuer]) => ({
recht,
bereich: "vms",
wofuer,
pflicht: true,
}));
if (version.major >= 9) {
liste.push(
{
recht: "VM.GuestAgent.Audit",
bereich: "vms",
wofuer: "IP-Adressen über den Guest-Agent",
pflicht: true,
},
{
recht: "VM.GuestAgent.Unrestricted",
bereich: "vms",
wofuer: "Provisionierung über den Guest-Agent (exec, file-write)",
pflicht: true,
},
);
} else {
liste.push({ recht: "VM.Monitor", bereich: "vms", wofuer: "Guest-Agent (in PVE 8)", pflicht: true });
}
liste.push(
{
recht: "Datastore.AllocateSpace",
bereich: "storage",
wofuer: "Disks auf dem Storage anlegen",
pflicht: true,
},
{
recht: "Datastore.AllocateTemplate",
bereich: "storage",
wofuer: "Images und LXC-Templates herunterladen",
pflicht: true,
},
{ recht: "Datastore.Audit", bereich: "storage", wofuer: "Storage-Inhalt lesen", pflicht: true },
{ recht: "SDN.Use", bereich: "sdn", wofuer: "Bridges und VNets an Gäste hängen", pflicht: true },
{ recht: "Sys.Audit", bereich: "wurzel", wofuer: "Cluster-Status und Rechte lesen", pflicht: true },
);
if (mindestens(version, 8, 2)) {
liste.push({
recht: "Sys.AccessNetwork",
bereich: "node",
wofuer: "Images per download-url laden",
pflicht: true,
});
} else {
liste.push({
recht: "Sys.Modify",
bereich: "wurzel",
wofuer: "Images per download-url laden (vor PVE 8.2)",
pflicht: true,
});
}
liste.push(
{
recht: "Datastore.Allocate",
bereich: "storage",
wofuer: "Heruntergeladene Images beim Aufräumen löschen",
pflicht: false,
},
{ recht: "SDN.Audit", bereich: "sdn", wofuer: "VNets anzeigen", pflicht: false },
{ recht: "VM.Console", bereich: "vms", wofuer: "Konsole öffnen (Link aus ProxDeploy)", pflicht: false },
);
if (version.major >= 9) {
liste.push({
recht: "Sys.Modify",
bereich: "wurzel",
wofuer: "Privilegierte Container anlegen (seit PVE 9)",
pflicht: false,
});
}
return liste;
}
/** Effektive Rechte je ACL-Pfad, wie `GET /access/permissions?path=…` sie liefert. */
export type RechteJePfad = Record<string, Record<string, number | boolean>>;
export type FehlendesRecht = { recht: string; pfad: string; wofuer: string; pflicht: boolean };
export function pfadFuer(bereich: RechtBereich, kontext: { storage?: string; node?: string }): string {
switch (bereich) {
case "vms":
return "/vms";
case "storage":
return kontext.storage ? `/storage/${kontext.storage}` : "/storage";
case "sdn":
return "/sdn/zones/localnetwork";
case "node":
return kontext.node ? `/nodes/${kontext.node}` : "/nodes";
case "wurzel":
return "/";
}
}
/**
* Vergleicht die effektiven Rechte mit der Anforderung. Storage-Rechte werden je Storage geprüft,
* der Gäste aufnehmen kann – gilt ein Recht auf keinem davon, fehlt es.
*/
export function rechtePruefen(
version: PveVersion,
rechte: RechteJePfad,
kontext: { storages: string[]; nodes: string[] },
): FehlendesRecht[] {
const hat = (pfad: string, recht: string) => Boolean(rechte[pfad]?.[recht]);
const fehlend: FehlendesRecht[] = [];
for (const anforderung of benoetigteRechte(version)) {
let pfade: string[];
if (anforderung.bereich === "storage") {
pfade = kontext.storages.map((storage) => pfadFuer("storage", { storage }));
} else if (anforderung.bereich === "node") {
pfade = kontext.nodes.map((node) => pfadFuer("node", { node }));
} else {
pfade = [pfadFuer(anforderung.bereich, {})];
}
if (pfade.length === 0) pfade = [pfadFuer(anforderung.bereich, {})];
// Storage: es genügt, wenn ein Storage passt. Nodes: jeder Node braucht es.
const erfuellt =
anforderung.bereich === "storage"
? pfade.some((p) => hat(p, anforderung.recht))
: pfade.every((p) => hat(p, anforderung.recht));
if (!erfuellt) {
const ohne =
anforderung.bereich === "storage" ? pfade : pfade.filter((p) => !hat(p, anforderung.recht));
fehlend.push({
recht: anforderung.recht,
pfad: ohne.join(", "),
wofuer: anforderung.wofuer,
pflicht: anforderung.pflicht,
});
}
}
return fehlend;
}
// ── Strategien ──────────────────────────────────────────────────────────────────────────────────
export type StorageInfo = {
storage: string;
typ: string;
inhalte: string[];
aktiv: boolean;
geteilt: boolean;
frei: number | null;
gesamt: number | null;
};
export type SshZustand = "aus" | "kein_schluessel" | "nicht_bestaetigt" | "fehler" | "ok";
export type Weg = "api" | "ssh" | "nicht_moeglich";
export type Strategie = { weg: Weg; grund: string };
export type Faehigkeiten = {
templateBau: Strategie;
vmProvisionierung: Strategie;
lxcProvisionierung: Strategie & { ueber?: "node" | "gast" };
snippets: Strategie;
lxcFeaturesErweitert: Strategie;
};
/**
* Leitet je Vorgang den Weg ab. Grundsatz: API, wo es geht; SSH nur, wo Proxmox keine API hat
* oder die Version zu alt ist.
*/
export function faehigkeitenAbleiten(eingabe: {
version: PveVersion;
storages: StorageInfo[];
ssh: SshZustand;
rechteFehlen: boolean;
}): Faehigkeiten {
const { version, storages, ssh } = eingabe;
const sshOk = ssh === "ok";
const importSpeicher = storages.filter((s) => s.aktiv && s.inhalte.includes("import"));
const snippetSpeicher = storages.filter((s) => s.aktiv && s.inhalte.includes("snippets"));
let templateBau: Strategie;
if (mindestens(version, 8, 4) && importSpeicher.length > 0) {
templateBau = {
weg: "api",
grund: `download-url und import-from über Storage ${importSpeicher.map((s) => s.storage).join(", ")}`,
};
} else if (sshOk) {
templateBau = {
weg: "ssh",
grund: mindestens(version, 8, 4)
? "Kein Storage mit Inhaltstyp „import“ – Download und Import laufen per SSH."
: `PVE ${version.text} kann Disk-Images erst ab 8.4 per API laden – Download und Import laufen per SSH.`,
};
} else {
templateBau = {
weg: "nicht_moeglich",
grund: mindestens(version, 8, 4)
? "Einem Storage den Inhaltstyp „Import“ geben (Datacenter → Storage) oder SSH einrichten."
: `PVE ${version.text} lädt Disk-Images erst ab 8.4 per API – SSH einrichten oder auf 8.4+ aktualisieren.`,
};
}
const snippets: Strategie =
sshOk && snippetSpeicher.length > 0
? { weg: "ssh", grund: `Snippets per SSH auf ${snippetSpeicher[0]!.storage}` }
: sshOk
? { weg: "nicht_moeglich", grund: "Kein Storage mit Inhaltstyp „Snippets“." }
: { weg: "nicht_moeglich", grund: "Proxmox hat keine API für Snippets – nur per SSH." };
return {
templateBau,
vmProvisionierung: { weg: "api", grund: "Über den Guest-Agent (im Template eingebaut)." },
lxcProvisionierung: sshOk
? { weg: "ssh", ueber: "node", grund: "pct exec über SSH zum Node." }
: {
weg: "ssh",
ueber: "gast",
grund:
"Proxmox hat keine Exec-API für Container – Profile laufen per SSH direkt in den Container (Netzweg nötig).",
},
snippets,
lxcFeaturesErweitert: sshOk
? { weg: "ssh", grund: "keyctl, fuse usw. per pct set über SSH." }
: {
weg: "nicht_moeglich",
grund: "Außer „nesting“ darf nur root@pam Container-Features setzen – per Token nicht.",
},
};
}
+2 -2
View File
@@ -117,10 +117,10 @@ export function rechtHinweis(meldung: string): string {
if (treffer) {
return (
`Der Rolle des Tokens fehlt ${treffer[2]} auf ${treffer[1]}. ` +
"Bei aktiver Privilege Separation braucht das Token die Rolle selbst (siehe README, Proxmox-Rechte)."
"Bei aktiver Privilege Separation braucht das Token die Rolle selbst (siehe docs/proxmox-rechte.md)."
);
}
return "Rechte des API-Tokens prüfen (siehe README, Proxmox-Rechte).";
return "Rechte des API-Tokens prüfen (siehe docs/proxmox-rechte.md).";
}
/** Übersetzt eine HTTP-Fehlerantwort der Proxmox-API. */
+145
View File
@@ -0,0 +1,145 @@
import { createHash } from "node:crypto";
import { Client } from "ssh2";
/**
* SSH zu Proxmox-Nodes oder Gästen. Der Host-Key wird wie das TLS-Zertifikat gepinnt: Ist kein
* Pin bekannt, darf nur „erkundet“ werden (der Schlüssel wird gemeldet und muss bestätigt
* werden); für alles Schreibende ist ein Pin Pflicht.
*/
export type SshZiel = {
host: string;
port: number;
benutzer: string;
privatSchluessel: string;
/** „SHA256:…“ wie ssh-keygen -lf; null nur beim Erkunden. */
hostSchluessel: string | null;
};
export type SshErgebnis = { code: number | null; stdout: string; stderr: string; hostSchluessel: string };
export class SshFehler extends Error {
constructor(
readonly art: "verbindung" | "anmeldung" | "host_schluessel" | "zeitueberschreitung" | "befehl",
readonly ursache: string,
readonly hinweis: string,
readonly hostSchluessel?: string,
) {
super(ursache);
this.name = "SshFehler";
}
}
/** Fingerprint im Format von ssh-keygen: SHA256:<base64 ohne Padding>. */
export function hostSchluesselFingerabdruck(schluessel: Buffer): string {
return `SHA256:${createHash("sha256").update(schluessel).digest("base64").replace(/=+$/, "")}`;
}
export function sshAusfuehren(
ziel: SshZiel,
befehl: string,
optionen: { zeitlimitMs?: number; eingabe?: string; erkunden?: boolean } = {},
): Promise<SshErgebnis> {
if (!ziel.hostSchluessel && !optionen.erkunden) {
return Promise.reject(
new SshFehler(
"host_schluessel",
`Für ${ziel.host} ist noch kein Host-Key bestätigt.`,
"Verbindungstest ausführen und den angezeigten Host-Key bestätigen.",
),
);
}
const zeitlimit = optionen.zeitlimitMs ?? 60_000;
return new Promise((erfuellen, ablehnen) => {
const client = new Client();
let gesehen = "";
let erledigt = false;
const ende = (fehler: SshFehler | null, ergebnis?: SshErgebnis) => {
if (erledigt) return;
erledigt = true;
clearTimeout(uhr);
client.end();
if (fehler) ablehnen(fehler);
else erfuellen(ergebnis!);
};
const uhr = setTimeout(
() =>
ende(
new SshFehler(
"zeitueberschreitung",
`SSH zu ${ziel.host} hat nach ${Math.round(zeitlimit / 1000)} s nicht geantwortet.`,
"Erreichbarkeit von Port " + ziel.port + " prüfen.",
gesehen || undefined,
),
),
zeitlimit,
);
client
.on("ready", () => {
client.exec(befehl, (fehler, kanal) => {
if (fehler) {
ende(
new SshFehler(
"befehl",
`Befehl ließ sich nicht starten: ${fehler.message}`,
"SSH-Benutzer prüfen.",
gesehen,
),
);
return;
}
let stdout = "";
let stderr = "";
let code: number | null = null;
kanal.on("data", (teil: Buffer) => (stdout += teil.toString("utf8")));
kanal.stderr.on("data", (teil: Buffer) => (stderr += teil.toString("utf8")));
kanal.on("exit", (exitCode: number | null) => (code = exitCode));
kanal.on("close", () => ende(null, { code, stdout, stderr, hostSchluessel: gesehen }));
if (optionen.eingabe !== undefined) kanal.end(optionen.eingabe);
else kanal.end();
});
})
.on("error", (fehler: Error & { level?: string; code?: string }) => {
if (fehler.level === "client-authentication") {
ende(
new SshFehler(
"anmeldung",
`${ziel.benutzer}@${ziel.host} nimmt den Schlüssel von ProxDeploy nicht an.`,
"Öffentlichen Schlüssel aus den Server-Einstellungen in ~/.ssh/authorized_keys des Nodes eintragen.",
gesehen || undefined,
),
);
} else if (fehler.message.includes("Host denied")) {
ende(
new SshFehler(
"host_schluessel",
`Der Host-Key von ${ziel.host} hat sich geändert (gesehen ${gesehen}).`,
"Wurde der Node neu installiert? Dann den Host-Key neu bestätigen. Sonst nicht verbinden.",
gesehen,
),
);
} else {
ende(
new SshFehler(
"verbindung",
`SSH zu ${ziel.host}:${ziel.port} fehlgeschlagen: ${fehler.code ?? fehler.message}`,
"Läuft sshd? Ist der Port aus dem Netz von ProxDeploy erreichbar?",
gesehen || undefined,
),
);
}
})
.connect({
host: ziel.host,
port: ziel.port,
username: ziel.benutzer,
privateKey: ziel.privatSchluessel,
readyTimeout: Math.min(zeitlimit, 20_000),
hostVerifier: (schluessel: Buffer) => {
gesehen = hostSchluesselFingerabdruck(schluessel);
return ziel.hostSchluessel ? gesehen === ziel.hostSchluessel : Boolean(optionen.erkunden);
},
});
});
}
+37
View File
@@ -0,0 +1,37 @@
/** Formate für die Anzeige – deutsch, gleich in Web und (sinngemäß) CLI. */
export function bytes(wert: number | null | undefined): string {
if (wert === null || wert === undefined) return "–";
const einheiten = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
let zahl = wert;
let stufe = 0;
while (zahl >= 1024 && stufe < einheiten.length - 1) {
zahl /= 1024;
stufe += 1;
}
return `${zahl.toLocaleString("de-DE", { maximumFractionDigits: zahl < 10 && stufe > 0 ? 1 : 0 })} ${einheiten[stufe]}`;
}
export function prozent(anteil: number | null | undefined): string {
if (anteil === null || anteil === undefined) return "–";
return `${Math.round(anteil * 100)} %`;
}
/** „vor 3 min“, „vor 2 h“, sonst Datum mit Uhrzeit. */
export function zeitpunkt(iso: string | null | undefined, jetzt = new Date()): string {
if (!iso) return "nie";
const datum = new Date(iso);
const sekunden = Math.round((jetzt.getTime() - datum.getTime()) / 1000);
if (sekunden < 60) return "gerade eben";
if (sekunden < 3600) return `vor ${Math.round(sekunden / 60)} min`;
if (sekunden < 86_400) return `vor ${Math.round(sekunden / 3600)} h`;
return datum.toLocaleString("de-DE", { dateStyle: "medium", timeStyle: "short" });
}
/** Dauer wie in der CLI: „18s“, „1m 12s“, „1h 03m“. */
export function dauer(ms: number): string {
const s = Math.max(0, Math.round(ms / 1000));
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`;
return `${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2, "0")}m`;
}
+12
View File
@@ -1,3 +1,4 @@
import type { VerbindungsBericht } from "@/engine/proxmox/erkundung";
import { z } from "@/lib/validierung";
/**
@@ -114,4 +115,15 @@ export type ServerSicht = {
erreichbar: boolean | null;
pveVersion: string | null;
geprueftAm: string | null;
letzterTest: VerbindungsBericht | null;
/** Gepinnte SSH-Host-Keys je Node – öffentlich, kein Geheimnis. */
sshHostSchluessel: Record<string, string>;
};
export const HostSchluesselBestaetigen = z.object({
knoten: z.string().trim().min(1),
hostSchluessel: z
.string()
.trim()
.regex(/^SHA256:[A-Za-z0-9+/]{43}$/, "Kein SHA256-Fingerprint eines Host-Keys."),
});
+1 -1
View File
@@ -2,7 +2,7 @@
* Version und Git-Stand für Seitenleiste, `/api/v1/ich` und die Kopfzeile der CLI.
* Beide Werte werden beim Bauen gesetzt (Docker-Build-Arg bzw. Installer).
*/
export const VERSION = process.env["NEXT_PUBLIC_APP_VERSION"] ?? "0.1.0-dev";
export const VERSION = process.env["NEXT_PUBLIC_APP_VERSION"] ?? "0.1.0";
export const GIT_SHA = (process.env["NEXT_PUBLIC_GIT_SHA"] ?? "").slice(0, 7);
export function versionText(): string {
+119 -1
View File
@@ -1,11 +1,18 @@
import { utils as sshUtils } from "ssh2";
import type { Server } from "@/generated/prisma/client";
import { ProxmoxClient, type ProxmoxVerbindung } from "@/engine/proxmox/client";
import { erkunden, type VerbindungsBericht } from "@/engine/proxmox/erkundung";
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
import { fingerabdruckNormalisieren, zertifikatHolen, type ZertifikatInfo } from "@/engine/proxmox/tls";
import { prisma } from "@/lib/db";
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
import { ServerAendern, ServerAnlegen, ServerPruefen, type ServerSicht } from "@/lib/schemas/server";
import {
HostSchluesselBestaetigen,
ServerAendern,
ServerAnlegen,
ServerPruefen,
type ServerSicht,
} from "@/lib/schemas/server";
import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
import type { Aufrufer } from "@/server/auth/zugang";
import { protokollieren } from "@/server/services/protokoll";
@@ -28,6 +35,8 @@ export function serverSicht(server: Server): ServerSicht {
erreichbar: server.erreichbar,
pveVersion: server.pveVersion,
geprueftAm: server.geprueftAm?.toISOString() ?? null,
letzterTest: (server.letzterTest as VerbindungsBericht | null) ?? null,
sshHostSchluessel: (server.sshHostSchluessel as Record<string, string> | null) ?? {},
};
}
@@ -259,3 +268,112 @@ export async function serverPruefen(roh: unknown): Promise<PruefErgebnis> {
await client.schliessen();
}
}
export type TestErgebnis =
{ ok: true; bericht: VerbindungsBericht } | { ok: false; art: string; ursache: string; hinweis: string };
/**
* Der Verbindungstest (#003): erkundet den Server, speichert den Bericht und die Erreichbarkeit.
* Ein nicht erreichbarer Server ist kein Fehler der Anfrage, sondern ein Ergebnis.
*/
export async function serverTesten(
name: string,
aufrufer: Aufrufer,
ip: string | null,
): Promise<TestErgebnis> {
const server = await serverHolen(name);
const client = new ProxmoxClient(verbindungAus(server), { zeitlimitMs: 15_000 });
let ergebnis: TestErgebnis;
try {
const bericht = await erkunden(client, {
serverHost: new URL(server.url).hostname,
ssh: server.sshAktiv
? {
benutzer: server.sshBenutzer,
port: server.sshPort,
privatSchluessel: server.sshPrivat ? entschluesseln(server.sshPrivat) : null,
hostSchluessel: (server.sshHostSchluessel as Record<string, string> | null) ?? {},
}
: null,
});
ergebnis = { ok: true, bericht };
} catch (fehler) {
if (!(fehler instanceof ProxmoxFehler)) throw fehler;
ergebnis = { ok: false, art: fehler.art, ursache: fehler.ursache, hinweis: fehler.hinweis };
} finally {
await client.schliessen();
}
await prisma.$transaction(async (tx) => {
await tx.server.update({
where: { id: server.id },
data: {
erreichbar: ergebnis.ok,
geprueftAm: new Date(),
...(ergebnis.ok ? { pveVersion: ergebnis.bericht.version, letzterTest: ergebnis.bericht } : {}),
},
});
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: "Verbindung getestet",
ziel: `server:${server.name}`,
details: ergebnis.ok
? { version: ergebnis.bericht.version, fehlendeRechte: ergebnis.bericht.fehlendeRechte.length }
: { fehler: ergebnis.ursache },
ip,
},
tx,
);
});
return ergebnis;
}
/**
* Pinnt den SSH-Host-Key eines Nodes – nur einen, den der letzte Test tatsächlich gesehen hat.
* So kann niemand über die API einen beliebigen Schlüssel unterschieben.
*/
export async function hostSchluesselBestaetigen(
name: string,
roh: unknown,
aufrufer: Aufrufer,
ip: string | null,
): Promise<ServerSicht> {
const server = await serverHolen(name);
const eingabe = HostSchluesselBestaetigen.parse(roh);
const bericht = server.letzterTest as VerbindungsBericht | null;
const gesehen = bericht?.knoten.find((k) => k.name === eingabe.knoten)?.ssh?.hostSchluessel;
if (!gesehen || gesehen !== eingabe.hostSchluessel) {
throw new AnwendungsFehler(
409,
"nicht_gesehen",
"Dieser Host-Key wurde beim letzten Verbindungstest nicht gesehen.",
"Verbindung erneut testen und den dann angezeigten Host-Key bestätigen.",
);
}
const neu = await prisma.$transaction(async (tx) => {
const aktualisiert = await tx.server.update({
where: { id: server.id },
data: {
sshHostSchluessel: {
...((server.sshHostSchluessel as Record<string, string> | null) ?? {}),
[eingabe.knoten]: eingabe.hostSchluessel,
},
},
});
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: "SSH-Host-Key bestätigt",
ziel: `server:${server.name}`,
details: { knoten: eingabe.knoten, hostSchluessel: eingabe.hostSchluessel },
ip,
},
tx,
);
return aktualisiert;
});
return serverSicht(neu);
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { ProxmoxMock } from "../proxmox-mock/mock";
import { szenarioEinrichten } from "../proxmox-mock/szenario";
/**
* Startet für die Abläufe einen Proxmox-Mock auf festem Port. Die gebaute Anwendung verbindet
@@ -8,7 +9,7 @@ export const MOCK_PORT = 18006;
export default async function aufbau(): Promise<() => Promise<void>> {
const mock = new ProxmoxMock();
mock.route("GET", "/version", () => ({ data: { version: "9.2.1", release: "9.2" } }));
szenarioEinrichten(mock, { version: "9.2.1", importStorage: true });
await mock.starten(MOCK_PORT);
return async () => {
await mock.stoppen();
+4 -1
View File
@@ -14,7 +14,10 @@ test("falsches Passwort zeigt eine Meldung mit Symbol und Text", async ({ page }
await page.getByRole("button", { name: "Anmelden" }).click();
// Wenn die Seite nicht hydriert, passiert hier nichts – das ist die eigentliche Prüfung.
await expect(page.getByText("Benutzername oder Passwort ist falsch.")).toBeVisible();
await expect(page.getByText("✗")).toBeVisible();
// Symbol + Text: das gezeichnete ✗ steht im selben Element wie die Meldung.
await expect(
page.getByText("Benutzername oder Passwort ist falsch.").locator("xpath=..").locator("svg"),
).toBeVisible();
});
test("Anmelden, Übersicht, Abmelden", async ({ page }) => {
+6
View File
@@ -28,6 +28,12 @@ test("Server anlegen: Zertifikat pinnen, Verbindung prüfen, SSH-Schlüssel, lö
await expect(page).toHaveURL(new RegExp(`/server/${name}$`));
await expect(page.getByRole("heading", { name: "E2E-Testserver" })).toBeVisible();
await page.getByRole("button", { name: "Verbindung testen" }).click();
await expect(page.getByRole("heading", { name: "Was geht auf diesem Server" })).toBeVisible();
await expect(page.getByText("Alle nötigen Rechte vorhanden.")).toBeVisible();
await expect(page.getByText("vmbr0", { exact: true })).toBeVisible();
await page.screenshot({ path: `test-results/server-test-${test.info().project.name}.png`, fullPage: true });
await page.getByRole("button", { name: "Schlüssel erzeugen" }).click();
await expect(page.getByText(/ssh-ed25519 AAAA/)).toBeVisible();
await keinQuerscrollen(page);
+186
View File
@@ -0,0 +1,186 @@
import { utils } from "ssh2";
import { afterEach, describe, expect, it } from "vitest";
import { ProxmoxClient } from "@/engine/proxmox/client";
import { erkunden } from "@/engine/proxmox/erkundung";
import { sshAusfuehren, SshFehler } from "@/engine/ssh/verbindung";
import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "../proxmox-mock/mock";
import { szenarioEinrichten, type Szenario } from "../proxmox-mock/szenario";
import { SshMock } from "../ssh-mock";
const aufraeumen: Array<() => Promise<void>> = [];
afterEach(async () => {
while (aufraeumen.length) await aufraeumen.pop()!();
});
async function mitSzenario(szenario: Szenario) {
const mock = await new ProxmoxMock().starten();
szenarioEinrichten(mock, szenario);
const client = new ProxmoxClient({
url: mock.url,
tokenId: MOCK_TOKEN_ID,
tokenGeheimnis: MOCK_TOKEN_GEHEIMNIS,
tlsPruefen: false,
fingerabdruck: mock.fingerabdruck,
});
aufraeumen.push(async () => {
await client.schliessen();
await mock.stoppen();
});
return { mock, client };
}
describe("Verbindungstest (Erkundung)", () => {
it("PVE 9.2 Einzelhost: Nodes, Storages, Bridges, keine fehlenden Rechte, Template-Bau per API", async () => {
const { client } = await mitSzenario({ version: "9.2.1", importStorage: true });
const bericht = await erkunden(client, { serverHost: "127.0.0.1", ssh: null });
expect(bericht.version).toBe("9.2.1");
expect(bericht.cluster).toBeNull();
expect(bericht.knoten).toHaveLength(1);
expect(bericht.knoten[0]!.storages.map((s) => s.storage)).toEqual(["local", "local-zfs"]);
expect(bericht.knoten[0]!.bridges).toEqual([
expect.objectContaining({ name: "vmbr0", vlanBewusst: true, cidr: "10.0.10.2/24", kommentar: "LAN" }),
expect.objectContaining({ name: "vmbr1", vlanBewusst: false }),
]);
expect(bericht.vnets).toEqual([{ name: "dmz", zone: "zone1", tag: 30, alias: "DMZ" }]);
expect(bericht.fehlendeRechte.filter((r) => r.pflicht)).toEqual([]);
expect(bericht.faehigkeiten.templateBau.weg).toBe("api");
expect(bericht.warnungen).toEqual([]);
});
it("Cluster: Name, Quorum und Node-IPs", async () => {
const { client } = await mitSzenario({
version: "9.2.1",
cluster: {
name: "rz1",
nodes: [
{ name: "pve01", ip: "10.0.0.1" },
{ name: "pve02", ip: "10.0.0.2" },
{ name: "pve03", ip: "10.0.0.3", online: false },
],
},
});
const bericht = await erkunden(client, { serverHost: "10.0.0.1", ssh: null });
expect(bericht.cluster).toEqual({ name: "rz1", quorate: true });
expect(bericht.knoten.map((k) => `${k.name}:${k.online}:${k.ip}`)).toEqual([
"pve01:true:10.0.0.1",
"pve02:true:10.0.0.2",
"pve03:false:10.0.0.3",
]);
expect(bericht.knoten[2]!.storages).toEqual([]);
});
it("PVE 8.3: Template-Bau braucht SSH, VM.Monitor wird verlangt", async () => {
const { client } = await mitSzenario({
version: "8.3.5",
importStorage: true,
ohneRechte: ["VM.Monitor"],
});
const bericht = await erkunden(client, { serverHost: "127.0.0.1", ssh: null });
expect(bericht.faehigkeiten.templateBau.weg).toBe("nicht_moeglich");
expect(bericht.fehlendeRechte).toContainEqual(
expect.objectContaining({ recht: "VM.Monitor", pfad: "/vms" }),
);
});
it("fehlende Leserechte werden zu Warnungen statt zum Abbruch", async () => {
const { client } = await mitSzenario({
version: "9.2.1",
ohneClusterStatus: true,
ohneRechte: ["SDN.Audit"],
});
const bericht = await erkunden(client, { serverHost: "127.0.0.1", ssh: null });
expect(bericht.knoten).toHaveLength(1);
expect(bericht.vnets).toBeNull();
expect(bericht.warnungen.join("\n")).toMatch(/Cluster-Status.*Sys\.Audit/);
});
it("warnt vor PVE 7", async () => {
const { client } = await mitSzenario({ version: "7.4.17" });
const bericht = await erkunden(client, { serverHost: "127.0.0.1", ssh: null });
expect(bericht.warnungen.join()).toMatch(/nicht unterstützt/);
});
});
describe("SSH", () => {
const paar = utils.generateKeyPairSync("ed25519");
async function sshServer() {
const ssh = await new SshMock(paar.public).starten();
aufraeumen.push(() => ssh.stoppen());
return ssh;
}
it("Erkunden liefert den Host-Key; danach nur noch mit Pin", async () => {
const ssh = await sshServer();
const ziel = {
host: "127.0.0.1",
port: ssh.port,
benutzer: "root",
privatSchluessel: paar.private,
hostSchluessel: null,
};
const erkundet = await sshAusfuehren(ziel, "pveversion", { erkunden: true });
expect(erkundet.hostSchluessel).toBe(ssh.hostSchluessel);
expect(erkundet.stdout).toContain("pve-manager/9.2.1");
await expect(sshAusfuehren(ziel, "pveversion")).rejects.toMatchObject({ art: "host_schluessel" });
const mitPin = await sshAusfuehren({ ...ziel, hostSchluessel: ssh.hostSchluessel }, "echo hallo");
expect(mitPin.code).toBe(0);
});
it("trennt bei geändertem Host-Key", async () => {
const ssh = await sshServer();
const fehler = await sshAusfuehren(
{
host: "127.0.0.1",
port: ssh.port,
benutzer: "root",
privatSchluessel: paar.private,
hostSchluessel: "SHA256:falsch",
},
"pveversion",
).catch((f: unknown) => f);
expect(fehler).toBeInstanceOf(SshFehler);
expect((fehler as SshFehler).art).toBe("host_schluessel");
expect(ssh.befehle).toEqual([]);
});
it("meldet einen nicht hinterlegten Schlüssel als Anmeldefehler mit Hinweis", async () => {
const ssh = await sshServer();
const fremd = utils.generateKeyPairSync("ed25519");
const fehler = (await sshAusfuehren(
{
host: "127.0.0.1",
port: ssh.port,
benutzer: "root",
privatSchluessel: fremd.private,
hostSchluessel: ssh.hostSchluessel,
},
"pveversion",
).catch((f: unknown) => f)) as SshFehler;
expect(fehler.art).toBe("anmeldung");
expect(fehler.hinweis).toMatch(/authorized_keys/);
});
it("Erkundung mit SSH: unbestätigt, dann bestätigt → Snippets und pct exec möglich", async () => {
const ssh = await sshServer();
const { client } = await mitSzenario({ version: "8.3.5", importStorage: true });
const zugang = { benutzer: "root", port: ssh.port, privatSchluessel: paar.private, hostSchluessel: {} };
const erst = await erkunden(client, { serverHost: "127.0.0.1", ssh: zugang });
expect(erst.ssh).toBe("nicht_bestaetigt");
expect(erst.knoten[0]!.ssh).toMatchObject({
zustand: "nicht_bestaetigt",
hostSchluessel: ssh.hostSchluessel,
});
expect(erst.faehigkeiten.templateBau.weg).toBe("nicht_moeglich");
const dann = await erkunden(client, {
serverHost: "127.0.0.1",
ssh: { ...zugang, hostSchluessel: { pve01: ssh.hostSchluessel } },
});
expect(dann.ssh).toBe("ok");
expect(dann.faehigkeiten.templateBau.weg).toBe("ssh");
expect(dann.faehigkeiten.lxcProvisionierung).toMatchObject({ ueber: "node" });
});
});
+163
View File
@@ -0,0 +1,163 @@
import { describe, expect, it } from "vitest";
import {
benoetigteRechte,
faehigkeitenAbleiten,
rechtePruefen,
versionLesen,
type StorageInfo,
} from "@/engine/proxmox/faehigkeiten";
const v = (text: string) => versionLesen(text)!;
const storage = (name: string, inhalte: string[]): StorageInfo => ({
storage: name,
typ: "dir",
inhalte,
aktiv: true,
geteilt: false,
frei: null,
gesamt: null,
});
describe("PVE-Version", () => {
it("liest übliche Schreibweisen", () => {
expect(v("9.2.1")).toMatchObject({ major: 9, minor: 2, patch: 1 });
expect(v("8.4")).toMatchObject({ major: 8, minor: 4, patch: 0 });
expect(versionLesen("kaputt")).toBeNull();
});
});
describe("Rolle ProxDeploy je Version", () => {
const namen = (text: string) =>
benoetigteRechte(v(text))
.filter((r) => r.pflicht)
.map((r) => r.recht);
it("PVE 9 nutzt die Guest-Agent-Rechte statt VM.Monitor", () => {
expect(namen("9.2.1")).toEqual(
expect.arrayContaining(["VM.GuestAgent.Audit", "VM.GuestAgent.Unrestricted"]),
);
expect(namen("9.2.1")).not.toContain("VM.Monitor");
});
it("PVE 8 braucht VM.Monitor für den Guest-Agent", () => {
expect(namen("8.4.1")).toContain("VM.Monitor");
expect(namen("8.4.1")).not.toContain("VM.GuestAgent.Unrestricted");
});
it("download-url: Sys.AccessNetwork ab 8.2, davor Sys.Modify", () => {
expect(namen("8.2.0")).toContain("Sys.AccessNetwork");
expect(namen("8.1.4")).toContain("Sys.Modify");
expect(namen("8.1.4")).not.toContain("Sys.AccessNetwork");
});
});
describe("Rechteprüfung", () => {
const version = v("9.2.1");
const voll = Object.fromEntries(benoetigteRechte(version).map((r) => [r.recht, 1]));
it("meldet nichts, wenn alles da ist", () => {
const rechte = {
"/": voll,
"/vms": voll,
"/sdn/zones/localnetwork": voll,
"/nodes/pve01": voll,
"/storage/local-zfs": voll,
};
expect(rechtePruefen(version, rechte, { storages: ["local-zfs"], nodes: ["pve01"] })).toEqual([]);
});
it("nennt fehlende Rechte mit Pfad und Zweck", () => {
const ohneAgent = { ...voll, "VM.GuestAgent.Unrestricted": 0 };
const rechte = {
"/": voll,
"/vms": ohneAgent,
"/sdn/zones/localnetwork": voll,
"/nodes/pve01": voll,
"/storage/local-zfs": voll,
};
expect(rechtePruefen(version, rechte, { storages: ["local-zfs"], nodes: ["pve01"] })).toEqual([
expect.objectContaining({ recht: "VM.GuestAgent.Unrestricted", pfad: "/vms", pflicht: true }),
]);
});
it("Storage-Rechte: einer reicht; Node-Rechte: jeder Node braucht sie", () => {
const rechte = {
"/": voll,
"/vms": voll,
"/sdn/zones/localnetwork": voll,
"/nodes/pve01": voll,
"/nodes/pve02": {},
"/storage/a": voll,
"/storage/b": {},
};
const fehlend = rechtePruefen(version, rechte, { storages: ["a", "b"], nodes: ["pve01", "pve02"] });
expect(fehlend.filter((f) => f.pflicht).map((f) => `${f.recht} ${f.pfad}`)).toEqual([
"Sys.AccessNetwork /nodes/pve02",
]);
});
});
describe("Strategien", () => {
const mitImport = [storage("local", ["iso", "import", "snippets"])];
const ohneImport = [storage("local", ["iso", "snippets"])];
it("PVE 9.2 mit import-Storage: Template-Bau per API, auch ohne SSH", () => {
const f = faehigkeitenAbleiten({
version: v("9.2.1"),
storages: mitImport,
ssh: "aus",
rechteFehlen: false,
});
expect(f.templateBau.weg).toBe("api");
expect(f.vmProvisionierung.weg).toBe("api");
expect(f.lxcProvisionierung).toMatchObject({ weg: "ssh", ueber: "gast" });
expect(f.snippets.weg).toBe("nicht_moeglich");
});
it("PVE 8.3: Template-Bau nur per SSH; ohne SSH mit Begründung unmöglich", () => {
expect(
faehigkeitenAbleiten({ version: v("8.3.5"), storages: mitImport, ssh: "ok", rechteFehlen: false })
.templateBau.weg,
).toBe("ssh");
const ohne = faehigkeitenAbleiten({
version: v("8.3.5"),
storages: mitImport,
ssh: "aus",
rechteFehlen: false,
});
expect(ohne.templateBau).toMatchObject({ weg: "nicht_moeglich" });
expect(ohne.templateBau.grund).toContain("8.4");
});
it("PVE 8.4 ohne import-Storage nennt den Ausweg", () => {
const f = faehigkeitenAbleiten({
version: v("8.4.1"),
storages: ohneImport,
ssh: "aus",
rechteFehlen: false,
});
expect(f.templateBau.grund).toMatch(/Inhaltstyp „Import“/);
});
it("mit SSH: Snippets, pct exec und erweiterte LXC-Features", () => {
const f = faehigkeitenAbleiten({
version: v("9.2.1"),
storages: mitImport,
ssh: "ok",
rechteFehlen: false,
});
expect(f.snippets.weg).toBe("ssh");
expect(f.lxcProvisionierung).toMatchObject({ ueber: "node" });
expect(f.lxcFeaturesErweitert.weg).toBe("ssh");
});
it("unbestätigter Host-Key zählt nicht als SSH", () => {
const f = faehigkeitenAbleiten({
version: v("8.3.5"),
storages: mitImport,
ssh: "nicht_bestaetigt",
rechteFehlen: false,
});
expect(f.templateBau.weg).toBe("nicht_moeglich");
});
});
+1 -1
View File
@@ -170,7 +170,7 @@ describe("ProxmoxClient", () => {
});
it("macht aus einem unbekannten Fehlertext einen allgemeinen Hinweis", () => {
expect(rechtHinweis("irgendwas")).toMatch(/README/);
expect(rechtHinweis("irgendwas")).toMatch(/proxmox-rechte\.md/);
});
});
+119
View File
@@ -0,0 +1,119 @@
import { benoetigteRechte, versionLesen } from "@/engine/proxmox/faehigkeiten";
import type { ProxmoxMock } from "./mock";
/**
* Typische Proxmox-Zustände für Tests. Die Antworten folgen dem Format der echten API
* (geprüft gegen das API-Schema von PVE 9.2).
*/
export type Szenario = {
version: string;
cluster?: { name: string; nodes: Array<{ name: string; ip: string; online?: boolean }> };
/** Storage `local` mit Inhaltstyp import? (ab 8.4 nötig für Template-Bau per API) */
importStorage?: boolean;
/** Rechte, die dem Token fehlen sollen. */
ohneRechte?: string[];
/** Ohne Sys.Audit auf / antwortet /cluster/status mit 403. */
ohneClusterStatus?: boolean;
};
export function szenarioEinrichten(mock: ProxmoxMock, szenario: Szenario): void {
const version = versionLesen(szenario.version)!;
const nodes = szenario.cluster?.nodes ?? [{ name: "pve01", ip: "127.0.0.1", online: true }];
const ohne = new Set(szenario.ohneRechte ?? []);
const alle = benoetigteRechte(version).map((r) => r.recht);
const rechte = Object.fromEntries(alle.filter((r) => !ohne.has(r)).map((r) => [r, 1]));
mock.route("GET", "/version", () => ({
data: { version: szenario.version, release: `${version.major}.${version.minor}`, repoid: "abcdef" },
}));
mock.route("GET", "/cluster/status", () =>
szenario.ohneClusterStatus
? { status: 403, statusText: "Permission check failed (/, Sys.Audit)" }
: {
data: [
...(szenario.cluster
? [
{
type: "cluster",
name: szenario.cluster.name,
quorate: 1,
nodes: nodes.length,
version: 3,
},
]
: []),
...nodes.map((n, i) => ({
type: "node",
name: n.name,
online: n.online === false ? 0 : 1,
ip: n.ip,
local: i === 0 ? 1 : 0,
nodeid: i + 1,
})),
],
},
);
mock.route("GET", "/nodes", () => ({
data: nodes.map((n) => ({
node: n.name,
status: n.online === false ? "offline" : "online",
cpu: 0.05,
maxcpu: 16,
mem: 8 * 1024 ** 3,
maxmem: 64 * 1024 ** 3,
uptime: 3600,
})),
}));
mock.route("GET", /^\/nodes\/([^/]+)\/version$/, () => ({
data: { version: szenario.version, release: `${version.major}.${version.minor}`, repoid: "abcdef" },
}));
mock.route("GET", /^\/nodes\/([^/]+)\/storage$/, () => ({
data: [
{
storage: "local",
type: "dir",
content: ["iso", "vztmpl", "backup", "snippets", ...(szenario.importStorage ? ["import"] : [])].join(
",",
),
active: 1,
enabled: 1,
shared: 0,
avail: 80 * 1024 ** 3,
total: 100 * 1024 ** 3,
},
{
storage: "local-zfs",
type: "zfspool",
content: "images,rootdir",
active: 1,
enabled: 1,
shared: 0,
avail: 800 * 1024 ** 3,
total: 1000 * 1024 ** 3,
},
],
}));
mock.route("GET", /^\/nodes\/([^/]+)\/network$/, () => ({
data: [
{ iface: "eno1", type: "eth", active: 1 },
{
iface: "vmbr0",
type: "bridge",
active: 1,
bridge_vlan_aware: 1,
cidr: "10.0.10.2/24",
comments: "LAN\n",
},
{ iface: "vmbr1", type: "bridge", active: 1 },
],
}));
mock.route("GET", "/cluster/sdn/vnets", () =>
ohne.has("SDN.Audit")
? { status: 403, statusText: "Permission check failed (/sdn, SDN.Audit)" }
: { data: [{ vnet: "dmz", zone: "zone1", tag: 30, alias: "DMZ" }] },
);
mock.route("GET", "/access/permissions", (anfrage) => {
const p = anfrage.abfrage.get("path") ?? "/";
return { data: { [p]: rechte } };
});
}
+25
View File
@@ -0,0 +1,25 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { benoetigteRechte, versionLesen } from "@/engine/proxmox/faehigkeiten";
/** Die Befehle in der Doku müssen genau die Rechte nennen, die der Verbindungstest verlangt. */
const doku = readFileSync("docs/proxmox-rechte.md", "utf8");
function befehlNach(ueberschrift: string): string[] {
const abschnitt = doku.slice(doku.indexOf(ueberschrift));
const treffer = /--privs "([^"]+)"/.exec(abschnitt);
return treffer![1]!.split(" ").sort();
}
describe("Doku der Proxmox-Rechte", () => {
it.each([
["## Rolle anlegen (PVE 9.x)", "9.2.1"],
["## Rolle anlegen (PVE 8.x)", "8.4.1"],
])("%s entspricht der Pflichtliste", (ueberschrift, version) => {
const pflicht = benoetigteRechte(versionLesen(version)!)
.filter((r) => r.pflicht)
.map((r) => r.recht)
.sort();
expect(befehlNach(ueberschrift)).toEqual(pflicht);
});
});
+66
View File
@@ -0,0 +1,66 @@
import type { AddressInfo } from "node:net";
import { Server, utils } from "ssh2";
import { hostSchluesselFingerabdruck } from "@/engine/ssh/verbindung";
/**
* Ein SSH-Server für Tests (ssh2 bringt ihn mit). Er nimmt genau einen öffentlichen Schlüssel an
* und beantwortet Befehle über eine frei definierbare Funktion.
*/
export class SshMock {
private server: Server | undefined;
port = 0;
hostSchluessel = "";
befehle: string[] = [];
constructor(
private readonly erlaubterSchluessel: string,
private readonly antwort: (befehl: string) => {
stdout?: string;
stderr?: string;
code?: number;
} = () => ({
stdout: "pve-manager/9.2.1/abcdef (running kernel: 6.14.8-2-pve)\n",
}),
) {}
async starten(): Promise<this> {
// ssh2 liest ed25519 nur im OpenSSH-Format, nicht als PKCS8.
const hostPem = utils.generateKeyPairSync("ed25519").private;
const host = utils.parseKey(hostPem);
if (host instanceof Error) throw host;
this.hostSchluessel = hostSchluesselFingerabdruck((Array.isArray(host) ? host[0]! : host).getPublicSSH());
const erlaubt = utils.parseKey(this.erlaubterSchluessel);
if (erlaubt instanceof Error) throw erlaubt;
const erlaubtSsh = (Array.isArray(erlaubt) ? erlaubt[0]! : erlaubt).getPublicSSH();
this.server = new Server({ hostKeys: [hostPem] }, (client) => {
client
.on("authentication", (kontext) => {
if (kontext.method === "publickey" && kontext.key.data.equals(erlaubtSsh)) kontext.accept();
else kontext.reject(["publickey"]);
})
.on("ready", () => {
client.on("session", (annehmen) => {
const sitzung = annehmen();
sitzung.on("exec", (annehmenExec, _ablehnen, info) => {
const kanal = annehmenExec();
this.befehle.push(info.command);
const ergebnis = this.antwort(info.command);
if (ergebnis.stdout) kanal.write(ergebnis.stdout);
if (ergebnis.stderr) kanal.stderr.write(ergebnis.stderr);
kanal.exit(ergebnis.code ?? 0);
kanal.end();
});
});
})
.on("error", () => undefined);
});
await new Promise<void>((erfuellen) => this.server!.listen(0, "127.0.0.1", erfuellen));
this.port = (this.server.address() as AddressInfo).port;
return this;
}
async stoppen(): Promise<void> {
await new Promise<void>((erfuellen) => this.server?.close(() => erfuellen()) ?? erfuellen());
}
}
+104
View File
@@ -0,0 +1,104 @@
import { utils } from "ssh2";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { POST as hostSchluesselPost } from "@/app/api/v1/server/[name]/ssh/hostschluessel/route";
import { POST as testPost } from "@/app/api/v1/server/[name]/test/route";
import { verschluesseln } from "@/lib/geheimnisse";
import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "./proxmox-mock/mock";
import { szenarioEinrichten } from "./proxmox-mock/szenario";
import { SshMock } from "./ssh-mock";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
import { angemeldet, anfrage, kontext } from "./helpers/anfragen";
const mock = new ProxmoxMock();
const paar = utils.generateKeyPairSync("ed25519");
let ssh: SshMock;
describe.runIf(datenbankVerfuegbar)("Verbindungstest über die API", () => {
beforeAll(async () => {
await mock.starten();
szenarioEinrichten(mock, { version: "8.3.5", importStorage: true });
ssh = await new SshMock(paar.public).starten();
});
afterAll(async () => {
await mock.stoppen();
await ssh.stoppen();
});
let admin: string;
let operator: string;
beforeEach(async () => {
await datenbankLeeren();
admin = (await angemeldet("ADMIN")).cookie;
operator = (await angemeldet("OPERATOR")).cookie;
await testPrisma.server.create({
data: {
name: "rz1",
titel: "RZ 1",
url: mock.url,
tokenId: MOCK_TOKEN_ID,
tokenGeheimnis: verschluesseln(MOCK_TOKEN_GEHEIMNIS),
fingerabdruck: mock.fingerabdruck,
sshAktiv: true,
sshPort: ssh.port,
sshPrivat: verschluesseln(paar.private),
sshOeffentlich: paar.public,
},
});
});
const testen = async (cookie: string) =>
await testPost(anfrage("POST", "/api/v1/server/rz1/test", cookie), kontext({ name: "rz1" }));
it("speichert Bericht, Version und Erreichbarkeit; Operator darf testen", async () => {
const antwort = await testen(operator);
expect(antwort.status).toBe(200);
const ergebnis = await antwort.json();
expect(ergebnis.ok).toBe(true);
expect(ergebnis.bericht.ssh).toBe("nicht_bestaetigt");
const server = await testPrisma.server.findUniqueOrThrow({ where: { name: "rz1" } });
expect(server).toMatchObject({ erreichbar: true, pveVersion: "8.3.5" });
expect(await testPrisma.protokoll.count({ where: { aktion: "Verbindung getestet" } })).toBe(1);
});
it("Host-Key bestätigen: nur der gesehene, nur durch Admins – danach geht SSH", async () => {
const erst = await (await testen(admin)).json();
const gesehen = erst.bericht.knoten[0].ssh.hostSchluessel as string;
const pfad = "/api/v1/server/rz1/ssh/hostschluessel";
expect(
(
await hostSchluesselPost(
anfrage("POST", pfad, operator, { knoten: "pve01", hostSchluessel: gesehen }),
kontext({ name: "rz1" }),
)
).status,
).toBe(403);
const untergeschoben = "SHA256:" + "A".repeat(43);
const abgelehnt = await hostSchluesselPost(
anfrage("POST", pfad, admin, { knoten: "pve01", hostSchluessel: untergeschoben }),
kontext({ name: "rz1" }),
);
expect(abgelehnt.status).toBe(409);
const ok = await hostSchluesselPost(
anfrage("POST", pfad, admin, { knoten: "pve01", hostSchluessel: gesehen }),
kontext({ name: "rz1" }),
);
expect(ok.status).toBe(200);
expect((await ok.json()).sshHostSchluessel).toEqual({ pve01: gesehen });
const dann = await (await testen(admin)).json();
expect(dann.bericht.ssh).toBe("ok");
expect(dann.bericht.faehigkeiten.templateBau.weg).toBe("ssh");
});
it("ein nicht erreichbarer Server ist ein Ergebnis, kein Fehler", async () => {
await testPrisma.server.update({ where: { name: "rz1" }, data: { url: "https://127.0.0.1:9" } });
const antwort = await testen(admin);
expect(antwort.status).toBe(200);
const ergebnis = await antwort.json();
expect(ergebnis).toMatchObject({ ok: false });
expect((await testPrisma.server.findUniqueOrThrow({ where: { name: "rz1" } })).erreichbar).toBe(false);
});
});