feat(server): Verbindungstest mit Fähigkeitserkennung
Der Test liest Version, Cluster, Nodes, Storages, Bridges, VNets und die effektiven Rechte des Tokens und leitet daraus ab, welcher Vorgang per API, per SSH oder gar nicht geht – je PVE-Version: Template-Bau per API erst ab 8.4 mit import-Storage, Snippets und LXC-Exec nur per SSH. Fehlende Rechte erscheinen einzeln mit Pfad und Zweck; die Rollenliste in docs/proxmox-rechte.md prüft ein Test gegen den Code. SSH-Host-Keys werden wie Zertifikate erst nach Bestätigung genutzt, und bestätigt werden kann nur ein Schlüssel, den der letzte Test gesehen hat. Statussymbole in der GUI sind jetzt gezeichnet: ◐ und ○ fehlen im Web-Subset von JetBrains Mono. Refs #003 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||||
@@ -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?
|
bauNetz Json?
|
||||||
/// Standard-Node, -Storage, -Bridge für den Assistenten.
|
/// Standard-Node, -Storage, -Bridge für den Assistenten.
|
||||||
standards Json?
|
standards Json?
|
||||||
/// Ergebnis der letzten Fähigkeitserkennung (#003).
|
/// Bericht des letzten Verbindungstests inkl. Fähigkeiten (VerbindungsBericht, #003).
|
||||||
faehigkeiten Json?
|
letzterTest Json?
|
||||||
erreichbar Boolean?
|
erreichbar Boolean?
|
||||||
pveVersion String?
|
pveVersion String?
|
||||||
geprueftAm DateTime?
|
geprueftAm DateTime?
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { seiteSchuetzen } from "@/server/auth/zugang";
|
|||||||
import { serverSicht } from "@/server/services/server";
|
import { serverSicht } from "@/server/services/server";
|
||||||
import { ServerStatus } from "../server-status";
|
import { ServerStatus } from "../server-status";
|
||||||
import { ServerAktionen } from "./server-aktionen";
|
import { ServerAktionen } from "./server-aktionen";
|
||||||
|
import { Verbindungsbericht } from "./verbindungsbericht";
|
||||||
|
|
||||||
export async function generateMetadata({ params }: { params: Promise<{ name: string }> }): Promise<Metadata> {
|
export async function generateMetadata({ params }: { params: Promise<{ name: string }> }): Promise<Metadata> {
|
||||||
return { title: (await params).name };
|
return { title: (await params).name };
|
||||||
@@ -67,6 +68,13 @@ export default async function ServerDetail({ params }: { params: Promise<{ name:
|
|||||||
</dl>
|
</dl>
|
||||||
</Karte>
|
</Karte>
|
||||||
|
|
||||||
|
<Verbindungsbericht
|
||||||
|
name={server.name}
|
||||||
|
bericht={server.letzterTest}
|
||||||
|
geprueftAm={server.geprueftAm}
|
||||||
|
verwalten={verwalten}
|
||||||
|
/>
|
||||||
|
|
||||||
<Karte titel="SSH zu den Nodes (optional)">
|
<Karte titel="SSH zu den Nodes (optional)">
|
||||||
{!server.sshAktiv ? (
|
{!server.sshAktiv ? (
|
||||||
<Status art="offen">Nicht genutzt. Deployments laufen dann nur über die API.</Status>
|
<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>
|
</p>
|
||||||
<Code>{`echo '${server.sshOeffentlich}' >> ~/.ssh/authorized_keys`}</Code>
|
<Code>{`echo '${server.sshOeffentlich}' >> ~/.ssh/authorized_keys`}</Code>
|
||||||
<p className="text-muted">
|
<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>
|
</p>
|
||||||
</div>
|
</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 als {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 { EINGABE_KLASSE, Feld, Schalter, beschreibung, Code } from "@/components/formular";
|
||||||
import { Karte } from "@/components/karte";
|
import { Karte } from "@/components/karte";
|
||||||
import { Knopf } from "@/components/knopf";
|
import { Knopf } from "@/components/knopf";
|
||||||
import { Status } from "@/components/status";
|
import { Status, StatusSymbol } from "@/components/status";
|
||||||
import { apiAufruf, ApiFehler } from "@/lib/api-client";
|
import { apiAufruf, ApiFehler } from "@/lib/api-client";
|
||||||
import type { ServerSicht } from "@/lib/schemas/server";
|
import type { ServerSicht } from "@/lib/schemas/server";
|
||||||
|
|
||||||
@@ -246,7 +246,7 @@ export function ServerFormular({ server }: { server?: ServerSicht }) {
|
|||||||
|
|
||||||
{felder["fingerabdruck"] ? (
|
{felder["fingerabdruck"] ? (
|
||||||
<p className="text-fehler text-sm">
|
<p className="text-fehler text-sm">
|
||||||
<span aria-hidden="true">✗ </span>
|
<StatusSymbol art="fehler" className="mr-1 inline translate-y-[0.125em]" />
|
||||||
{felder["fingerabdruck"]}
|
{felder["fingerabdruck"]}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -295,7 +295,7 @@ export function ServerFormular({ server }: { server?: ServerSicht }) {
|
|||||||
</Feld>
|
</Feld>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted text-sm">
|
<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>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
<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 { clsx } from "clsx";
|
||||||
|
import { StatusSymbol } from "@/components/status";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formular-Bausteine: echtes `<label>`, Fehlermeldung per `aria-describedby` verbunden,
|
* Formular-Bausteine: echtes `<label>`, Fehlermeldung per `aria-describedby` verbunden,
|
||||||
@@ -30,7 +31,7 @@ export function Feld({
|
|||||||
{children}
|
{children}
|
||||||
{fehler ? (
|
{fehler ? (
|
||||||
<p id={`${id}-fehler`} className="text-fehler text-sm">
|
<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}
|
{fehler}
|
||||||
</p>
|
</p>
|
||||||
) : hilfe ? (
|
) : hilfe ? (
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Status immer als Symbol + Farbe + Text, nie nur Farbe (Vorgabe Barrierearmut).
|
* Status immer als Symbol + Farbe + Text, nie nur Farbe (Vorgabe Barrierearmut). Die Symbole
|
||||||
* Dieselben Symbole wie in der CLI-Ausgabe.
|
* 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";
|
export type StatusArt = "erledigt" | "laeuft" | "fehler" | "offen";
|
||||||
|
|
||||||
|
/** Die Zeichen der CLI – für Textausgaben (Terminal-Karte, Kopiertext). */
|
||||||
export const STATUS_SYMBOL: Record<StatusArt, string> = {
|
export const STATUS_SYMBOL: Record<StatusArt, string> = {
|
||||||
erledigt: "✓",
|
erledigt: "✓",
|
||||||
laeuft: "◐",
|
laeuft: "◐",
|
||||||
@@ -20,6 +22,31 @@ const STATUS_FARBE: Record<StatusArt, string> = {
|
|||||||
offen: "text-muted",
|
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({
|
export function Status({
|
||||||
art,
|
art,
|
||||||
children,
|
children,
|
||||||
@@ -30,10 +57,8 @@ export function Status({
|
|||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<span className={clsx("inline-flex items-center gap-1.5", STATUS_FARBE[art], className)}>
|
<span className={clsx("inline-flex items-baseline gap-1.5", STATUS_FARBE[art], className)}>
|
||||||
<span aria-hidden="true" className="font-mono">
|
<StatusSymbol art={art} className="translate-y-[0.125em]" />
|
||||||
{STATUS_SYMBOL[art]}
|
|
||||||
</span>
|
|
||||||
<span>{children}</span>
|
<span>{children}</span>
|
||||||
</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) {
|
if (treffer) {
|
||||||
return (
|
return (
|
||||||
`Der Rolle des Tokens fehlt ${treffer[2]} auf ${treffer[1]}. ` +
|
`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. */
|
/** Ü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";
|
import { z } from "@/lib/validierung";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -114,4 +115,15 @@ export type ServerSicht = {
|
|||||||
erreichbar: boolean | null;
|
erreichbar: boolean | null;
|
||||||
pveVersion: string | null;
|
pveVersion: string | null;
|
||||||
geprueftAm: 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,11 +1,18 @@
|
|||||||
import { utils as sshUtils } from "ssh2";
|
import { utils as sshUtils } from "ssh2";
|
||||||
import type { Server } from "@/generated/prisma/client";
|
import type { Server } from "@/generated/prisma/client";
|
||||||
import { ProxmoxClient, type ProxmoxVerbindung } from "@/engine/proxmox/client";
|
import { ProxmoxClient, type ProxmoxVerbindung } from "@/engine/proxmox/client";
|
||||||
|
import { erkunden, type VerbindungsBericht } from "@/engine/proxmox/erkundung";
|
||||||
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
|
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
|
||||||
import { fingerabdruckNormalisieren, zertifikatHolen, type ZertifikatInfo } from "@/engine/proxmox/tls";
|
import { fingerabdruckNormalisieren, zertifikatHolen, type ZertifikatInfo } from "@/engine/proxmox/tls";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
|
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 { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
|
||||||
import type { Aufrufer } from "@/server/auth/zugang";
|
import type { Aufrufer } from "@/server/auth/zugang";
|
||||||
import { protokollieren } from "@/server/services/protokoll";
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
@@ -28,6 +35,8 @@ export function serverSicht(server: Server): ServerSicht {
|
|||||||
erreichbar: server.erreichbar,
|
erreichbar: server.erreichbar,
|
||||||
pveVersion: server.pveVersion,
|
pveVersion: server.pveVersion,
|
||||||
geprueftAm: server.geprueftAm?.toISOString() ?? null,
|
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();
|
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 { 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
|
* 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>> {
|
export default async function aufbau(): Promise<() => Promise<void>> {
|
||||||
const mock = new ProxmoxMock();
|
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);
|
await mock.starten(MOCK_PORT);
|
||||||
return async () => {
|
return async () => {
|
||||||
await mock.stoppen();
|
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();
|
await page.getByRole("button", { name: "Anmelden" }).click();
|
||||||
// Wenn die Seite nicht hydriert, passiert hier nichts – das ist die eigentliche Prüfung.
|
// 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("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 }) => {
|
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).toHaveURL(new RegExp(`/server/${name}$`));
|
||||||
await expect(page.getByRole("heading", { name: "E2E-Testserver" })).toBeVisible();
|
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 page.getByRole("button", { name: "Schlüssel erzeugen" }).click();
|
||||||
await expect(page.getByText(/ssh-ed25519 AAAA/)).toBeVisible();
|
await expect(page.getByText(/ssh-ed25519 AAAA/)).toBeVisible();
|
||||||
await keinQuerscrollen(page);
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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