Compare commits
3
Commits
48e2a5cb8f
...
a9a48dccfa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9a48dccfa | ||
|
|
d64b8fb24d | ||
|
|
86b650e20a |
+15
-6
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 | – |
|
||||
|
||||
@@ -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
@@ -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";
|
||||
@@ -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?
|
||||
|
||||
@@ -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,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)),
|
||||
);
|
||||
});
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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.",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 } };
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user