Merge branch 'phase/016-017-schluesselseite'
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 148 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 124 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 209 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 214 KiB |
@@ -1,10 +1,35 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
import { AusrollDialog } from "@/components/ausrollen/AusrollDialog";
|
||||||
* Platzhalter fuer „+ Ausrollen“ in der Karte „Private Key ausgerollt auf“ (#012).
|
import { PrivateZuweisenFormular } from "@/components/ausrollen/PrivateZuweisenFormular";
|
||||||
* Inhalt folgt mit #017 (Private Key auf einen Host legen); bis dahin bewusst leer.
|
import type { ZielAuswahl } from "@/server/queries/ziele";
|
||||||
*/
|
|
||||||
export function PrivateAusrollenAktion(props: { schluesselId: string; darf: boolean }): null {
|
/** „+ Ausrollen“ in der Karte „Private Key ausgerollt auf“ (#017) – nur Admins. */
|
||||||
void props;
|
export function PrivateAusrollenAktion({
|
||||||
return null;
|
schluesselId,
|
||||||
|
darf,
|
||||||
|
auswahl,
|
||||||
|
slug,
|
||||||
|
passphraseGeschuetzt = false,
|
||||||
|
}: {
|
||||||
|
schluesselId: string;
|
||||||
|
darf: boolean;
|
||||||
|
auswahl?: ZielAuswahl;
|
||||||
|
slug?: string;
|
||||||
|
passphraseGeschuetzt?: boolean;
|
||||||
|
}) {
|
||||||
|
if (!darf || !auswahl || !slug) return null;
|
||||||
|
return (
|
||||||
|
<AusrollDialog titel="Private Key ausrollen">
|
||||||
|
{(schliessen) => (
|
||||||
|
<PrivateZuweisenFormular
|
||||||
|
schluesselId={schluesselId}
|
||||||
|
slug={slug}
|
||||||
|
auswahl={auswahl}
|
||||||
|
passphraseGeschuetzt={passphraseGeschuetzt}
|
||||||
|
beiErfolg={schliessen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AusrollDialog>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,25 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
import { AusrollDialog } from "@/components/ausrollen/AusrollDialog";
|
||||||
* Platzhalter fuer „+ Ausrollen“ in der Karte „Public Key ausgerollt auf“ (#012).
|
import { PublicZuweisenFormular } from "@/components/ausrollen/PublicZuweisenFormular";
|
||||||
* Inhalt folgt mit #016 (Zuweisung, Dry-Run, Ausrollen); bis dahin bewusst leer.
|
import type { ZielAuswahl } from "@/server/queries/ziele";
|
||||||
*/
|
|
||||||
export function PublicAusrollenAktion(props: { schluesselId: string; darf: boolean }): null {
|
/** „+ Ausrollen“ in der Karte „Public Key ausgerollt auf“ (#016). */
|
||||||
void props;
|
export function PublicAusrollenAktion({
|
||||||
return null;
|
schluesselId,
|
||||||
|
darf,
|
||||||
|
auswahl,
|
||||||
|
}: {
|
||||||
|
schluesselId: string;
|
||||||
|
darf: boolean;
|
||||||
|
auswahl?: ZielAuswahl;
|
||||||
|
}) {
|
||||||
|
if (!darf || !auswahl) return null;
|
||||||
|
return (
|
||||||
|
<AusrollDialog titel="Public Key zuweisen">
|
||||||
|
{(schliessen) => (
|
||||||
|
<PublicZuweisenFormular schluesselId={schluesselId} auswahl={auswahl} beiErfolg={schliessen} />
|
||||||
|
)}
|
||||||
|
</AusrollDialog>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import { datum, datumZeit } from "@/lib/format";
|
|||||||
import { darf } from "@/server/auth/rechte";
|
import { darf } from "@/server/auth/rechte";
|
||||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
import { schluesselDetail, type SchluesselDetail } from "@/server/queries/schluessel";
|
import { schluesselDetail, type SchluesselDetail } from "@/server/queries/schluessel";
|
||||||
|
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
||||||
|
import { EintragAktionen } from "@/components/ausrollen/EintragAktionen";
|
||||||
|
import { zielAuswahl } from "@/server/queries/ziele";
|
||||||
import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion";
|
import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion";
|
||||||
import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen";
|
import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen";
|
||||||
import { PublicAusrollenAktion } from "./PublicAusrollenAktion";
|
import { PublicAusrollenAktion } from "./PublicAusrollenAktion";
|
||||||
@@ -40,7 +43,15 @@ const ZUSTAND: Record<string, { status: HostStatus; text: string }> = {
|
|||||||
FEHLGESCHLAGEN: { status: "drift", text: "fehlgeschlagen" },
|
FEHLGESCHLAGEN: { status: "drift", text: "fehlgeschlagen" },
|
||||||
};
|
};
|
||||||
|
|
||||||
type Eintrag = { id: string; ziel: string; href?: string; pfad: string; zusatz?: string; zustand: string };
|
type Eintrag = {
|
||||||
|
id: string;
|
||||||
|
ziel: string;
|
||||||
|
href?: string;
|
||||||
|
pfad: string;
|
||||||
|
zusatz?: string;
|
||||||
|
zustand: string;
|
||||||
|
aktionen?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
function AusrollKarte({
|
function AusrollKarte({
|
||||||
titel,
|
titel,
|
||||||
@@ -67,7 +78,8 @@ function AusrollKarte({
|
|||||||
const z = ZUSTAND[e.zustand] ?? { status: "unbekannt" as const, text: e.zustand };
|
const z = ZUSTAND[e.zustand] ?? { status: "unbekannt" as const, text: e.zustand };
|
||||||
return (
|
return (
|
||||||
<li key={e.id}>
|
<li key={e.id}>
|
||||||
<InnereBox className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
<InnereBox className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-text font-mono text-[13.5px] font-medium break-all">
|
<p className="text-text font-mono text-[13.5px] font-medium break-all">
|
||||||
{e.href ? (
|
{e.href ? (
|
||||||
@@ -82,6 +94,9 @@ function AusrollKarte({
|
|||||||
{e.zusatz && <p className="text-muted font-mono text-[12.5px] break-all">{e.zusatz}</p>}
|
{e.zusatz && <p className="text-muted font-mono text-[12.5px] break-all">{e.zusatz}</p>}
|
||||||
</div>
|
</div>
|
||||||
<Statuspunkt status={z.status} text={z.text} className="shrink-0 sm:pt-0.5" />
|
<Statuspunkt status={z.status} text={z.text} className="shrink-0 sm:pt-0.5" />
|
||||||
|
</div>
|
||||||
|
{/* Aktionen unter dem Eintrag, damit lange Hostnamen nicht umbrechen (#016). */}
|
||||||
|
{e.aktionen}
|
||||||
</InnereBox>
|
</InnereBox>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -92,7 +107,7 @@ function AusrollKarte({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function privateEintraege(s: SchluesselDetail): Eintrag[] {
|
function privateEintraege(s: SchluesselDetail, rechte: Rechte): Eintrag[] {
|
||||||
return s.privateVerwendungen.map((v) => {
|
return s.privateVerwendungen.map((v) => {
|
||||||
const config =
|
const config =
|
||||||
v.sshConfig && typeof v.sshConfig === "object" ? (v.sshConfig as { alias?: unknown }) : null;
|
v.sshConfig && typeof v.sshConfig === "object" ? (v.sshConfig as { alias?: unknown }) : null;
|
||||||
@@ -103,11 +118,22 @@ function privateEintraege(s: SchluesselDetail): Eintrag[] {
|
|||||||
pfad: v.pfad,
|
pfad: v.pfad,
|
||||||
...(typeof config?.alias === "string" ? { zusatz: `config: Host ${config.alias}` } : {}),
|
...(typeof config?.alias === "string" ? { zusatz: `config: Host ${config.alias}` } : {}),
|
||||||
zustand: v.zustand,
|
zustand: v.zustand,
|
||||||
|
aktionen: (
|
||||||
|
<EintragAktionen
|
||||||
|
art="privat"
|
||||||
|
verwendungId={v.id}
|
||||||
|
schluesselId={s.id}
|
||||||
|
darfEntfernen={rechte.privat && v.zustand !== "ENTFERNEN"}
|
||||||
|
testbar={rechte.testen && v.zustand === "AUSGEROLLT" && typeof config?.alias === "string"}
|
||||||
|
/>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function publicEintraege(s: SchluesselDetail): Eintrag[] {
|
type Rechte = { readonly public: boolean; readonly privat: boolean; readonly testen: boolean };
|
||||||
|
|
||||||
|
function publicEintraege(s: SchluesselDetail, rechte: Rechte): Eintrag[] {
|
||||||
return s.publicVerwendungen.map((v) => ({
|
return s.publicVerwendungen.map((v) => ({
|
||||||
id: v.id,
|
id: v.id,
|
||||||
ziel: v.host
|
ziel: v.host
|
||||||
@@ -121,6 +147,15 @@ function publicEintraege(s: SchluesselDetail): Eintrag[] {
|
|||||||
pfad: "~/.ssh/authorized_keys",
|
pfad: "~/.ssh/authorized_keys",
|
||||||
...(v.optionen ? { zusatz: v.optionen } : {}),
|
...(v.optionen ? { zusatz: v.optionen } : {}),
|
||||||
zustand: v.zustand,
|
zustand: v.zustand,
|
||||||
|
aktionen: (
|
||||||
|
<EintragAktionen
|
||||||
|
art="public"
|
||||||
|
verwendungId={v.id}
|
||||||
|
schluesselId={s.id}
|
||||||
|
darfEntfernen={rechte.public && v.zustand !== "ENTFERNEN"}
|
||||||
|
testbar={false}
|
||||||
|
/>
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +194,12 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
|
|||||||
if (!s) notFound();
|
if (!s) notFound();
|
||||||
|
|
||||||
const aktiv = s.status === "AKTIV";
|
const aktiv = s.status === "AKTIV";
|
||||||
|
const rechte: Rechte = {
|
||||||
|
public: darf(benutzer.rolle, "publicKeyAusrollen"),
|
||||||
|
privat: darf(benutzer.rolle, "privateKeyAusrollen"),
|
||||||
|
testen: darf(benutzer.rolle, "hostsVerwalten"),
|
||||||
|
};
|
||||||
|
const auswahl = aktiv && rechte.public ? await zielAuswahl() : undefined;
|
||||||
const personenKey = s.kategorie === "PERSON";
|
const personenKey = s.kategorie === "PERSON";
|
||||||
const verwalten = darf(benutzer.rolle, "schluesselVerwalten");
|
const verwalten = darf(benutzer.rolle, "schluesselVerwalten");
|
||||||
// Den Deployment-Key gibt sshkv nie heraus (#014) – Knoepfe dafuer erscheinen gar nicht erst.
|
// Den Deployment-Key gibt sshkv nie heraus (#014) – Knoepfe dafuer erscheinen gar nicht erst.
|
||||||
@@ -243,7 +284,7 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
|
|||||||
<AusrollKarte
|
<AusrollKarte
|
||||||
titel="Private Key ausgerollt auf"
|
titel="Private Key ausgerollt auf"
|
||||||
untertitel="Hosts, die sich mit diesem Schlüssel anmelden"
|
untertitel="Hosts, die sich mit diesem Schlüssel anmelden"
|
||||||
eintraege={privateEintraege(s)}
|
eintraege={privateEintraege(s, rechte)}
|
||||||
leer={
|
leer={
|
||||||
s.privatGespeichert
|
s.privatGespeichert
|
||||||
? "Der Private Key liegt noch auf keinem Host."
|
? "Der Private Key liegt noch auf keinem Host."
|
||||||
@@ -252,7 +293,10 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
|
|||||||
aktion={
|
aktion={
|
||||||
<PrivateAusrollenAktion
|
<PrivateAusrollenAktion
|
||||||
schluesselId={s.id}
|
schluesselId={s.id}
|
||||||
darf={aktiv && s.privatGespeichert && darf(benutzer.rolle, "privateKeyAusrollen")}
|
darf={aktiv && s.privatGespeichert && rechte.privat}
|
||||||
|
{...(auswahl ? { auswahl } : {})}
|
||||||
|
slug={s.slug}
|
||||||
|
passphraseGeschuetzt={s.passphraseGeschuetzt}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -260,7 +304,7 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
|
|||||||
<AusrollKarte
|
<AusrollKarte
|
||||||
titel="Public Key ausgerollt auf"
|
titel="Public Key ausgerollt auf"
|
||||||
untertitel="Konten, die diesen Schlüssel annehmen"
|
untertitel="Konten, die diesen Schlüssel annehmen"
|
||||||
eintraege={publicEintraege(s)}
|
eintraege={publicEintraege(s, rechte)}
|
||||||
leer={
|
leer={
|
||||||
personenKey
|
personenKey
|
||||||
? "Noch keinem Konto zugewiesen – wirksam erst, wenn ein Operator oder Admin den Schlüssel einem Host zuweist."
|
? "Noch keinem Konto zugewiesen – wirksam erst, wenn ein Operator oder Admin den Schlüssel einem Host zuweist."
|
||||||
@@ -269,12 +313,29 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
|
|||||||
aktion={
|
aktion={
|
||||||
<PublicAusrollenAktion
|
<PublicAusrollenAktion
|
||||||
schluesselId={s.id}
|
schluesselId={s.id}
|
||||||
darf={aktiv && darf(benutzer.rolle, "publicKeyAusrollen")}
|
darf={aktiv && rechte.public}
|
||||||
|
{...(auswahl ? { auswahl } : {})}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{rechte.public &&
|
||||||
|
(s.publicVerwendungen.length > 0 || (rechte.privat && s.privateVerwendungen.length > 0)) && (
|
||||||
|
<Karte
|
||||||
|
titel="Dry-Run-Vorschau"
|
||||||
|
untertitel="Erst prüfen, dann anwenden – auf den Hosts ändert sich vorher nichts"
|
||||||
|
>
|
||||||
|
<AusrollenPanel
|
||||||
|
auswahl={{ schluesselId: s.id }}
|
||||||
|
offen={
|
||||||
|
s.publicVerwendungen.filter((v) => v.zustand !== "AUSGEROLLT").length +
|
||||||
|
(rechte.privat ? s.privateVerwendungen.filter((v) => v.zustand !== "AUSGEROLLT").length : 0)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
<Karte
|
<Karte
|
||||||
titel="Public Key"
|
titel="Public Key"
|
||||||
untertitel={
|
untertitel={
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Plus, X } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „+ Ausrollen“: Knopf mit nativem Dialog (#016, #017). Offen/zu als Zustand, der Effekt
|
||||||
|
* gleicht den Dialog an – so liest kein Render-Pfad die Ref.
|
||||||
|
*/
|
||||||
|
export function AusrollDialog({
|
||||||
|
titel,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
titel: string;
|
||||||
|
children: (schliessen: () => void) => ReactNode;
|
||||||
|
}) {
|
||||||
|
const dialog = useRef<HTMLDialogElement>(null);
|
||||||
|
const [offen, setOffen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = dialog.current;
|
||||||
|
if (!element) return;
|
||||||
|
if (offen && !element.open) element.showModal();
|
||||||
|
if (!offen && element.open) element.close();
|
||||||
|
}, [offen]);
|
||||||
|
|
||||||
|
const schliessen = () => setOffen(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button klein icon={<Plus className="size-4" />} onClick={() => setOffen(true)}>
|
||||||
|
Ausrollen
|
||||||
|
</Button>
|
||||||
|
<dialog
|
||||||
|
ref={dialog}
|
||||||
|
aria-label={titel}
|
||||||
|
onClose={schliessen}
|
||||||
|
className="border-border-strong bg-surface text-text rounded-karte m-auto w-[calc(100%-32px)] max-w-lg border p-5 backdrop:bg-black/70 sm:p-6"
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-start justify-between gap-3">
|
||||||
|
<h2 className="font-display text-[18px] font-semibold">{titel}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={schliessen}
|
||||||
|
aria-label="Schließen"
|
||||||
|
className="text-muted hover:text-text hover:bg-raised rounded-feld -mt-2 -mr-2 flex size-11 items-center justify-center"
|
||||||
|
>
|
||||||
|
<X className="size-5" aria-hidden />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{offen && children(schliessen)}
|
||||||
|
</dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,7 +14,17 @@ import type { DiffZeile } from "@/lib/ssh/zeilen-diff";
|
|||||||
* schreibt: der Plan traegt die Hashes, und das Ausrollen bricht ab, wenn sich seitdem auf
|
* schreibt: der Plan traegt die Hashes, und das Ausrollen bricht ab, wenn sich seitdem auf
|
||||||
* dem Host oder an den Zuweisungen etwas geaendert hat.
|
* dem Host oder an den Zuweisungen etwas geaendert hat.
|
||||||
*/
|
*/
|
||||||
|
type DateiPlan = {
|
||||||
|
art: "privateKey" | "publicKey" | "config" | "knownHosts";
|
||||||
|
pfad: string;
|
||||||
|
aktion: "anlegen" | "aendern" | "keine" | "entfernen";
|
||||||
|
diff?: DiffZeile[];
|
||||||
|
hinweis?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type PlanZiel = {
|
type PlanZiel = {
|
||||||
|
art?: "public" | "privat";
|
||||||
|
dateien?: DateiPlan[];
|
||||||
hostName: string;
|
hostName: string;
|
||||||
zielbenutzer: string;
|
zielbenutzer: string;
|
||||||
pfad?: string;
|
pfad?: string;
|
||||||
@@ -77,6 +87,8 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of
|
|||||||
<p className="border-danger-border bg-danger-soft text-danger rounded-box border px-4 py-3 font-mono text-[13px]">
|
<p className="border-danger-border bg-danger-soft text-danger rounded-box border px-4 py-3 font-mono text-[13px]">
|
||||||
{ziel.zielbenutzer}@{ziel.hostName}: {ziel.fehler}
|
{ziel.zielbenutzer}@{ziel.hostName}: {ziel.fehler}
|
||||||
</p>
|
</p>
|
||||||
|
) : ziel.art === "privat" ? (
|
||||||
|
<PrivatPlanAnzeige ziel={ziel} />
|
||||||
) : (
|
) : (
|
||||||
<DiffBox
|
<DiffBox
|
||||||
datei={`${ziel.zielbenutzer}@${ziel.hostName}:${ziel.pfad ?? "~/.ssh/authorized_keys"}`}
|
datei={`${ziel.zielbenutzer}@${ziel.hostName}:${ziel.pfad ?? "~/.ssh/authorized_keys"}`}
|
||||||
@@ -95,8 +107,9 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of
|
|||||||
<div className="border-border flex flex-col gap-3 border-t pt-4">
|
<div className="border-border flex flex-col gap-3 border-t pt-4">
|
||||||
<label className="text-muted flex min-h-11 items-center gap-3 text-[14px]">
|
<label className="text-muted flex min-h-11 items-center gap-3 text-[14px]">
|
||||||
<input type="checkbox" checked disabled className="accent-accent size-5" />
|
<input type="checkbox" checked disabled className="accent-accent size-5" />
|
||||||
Backup jeder Datei anlegen (
|
Backup jeder geänderten Datei anlegen (
|
||||||
<code className="font-mono text-[12.5px]">authorized_keys.sshkv.bak.…</code>)
|
<code className="font-mono text-[12.5px]">….sshkv.bak.…</code>) – außer bei Private Keys, von
|
||||||
|
denen keine zweite Kopie liegen bleiben soll
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Button
|
<Button
|
||||||
@@ -131,3 +144,49 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AKTION_TEXT: Record<DateiPlan["aktion"], string> = {
|
||||||
|
anlegen: "wird angelegt",
|
||||||
|
aendern: "wird geändert",
|
||||||
|
entfernen: "wird entfernt",
|
||||||
|
keine: "unverändert",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Private-Key-Ziel (#017): die Dateien unter ~/.ssh einzeln. Vom Private Key steht nur die
|
||||||
|
* Aktion da – sein Inhalt erreicht den Browser nie.
|
||||||
|
*/
|
||||||
|
function PrivatPlanAnzeige({ ziel }: { ziel: PlanZiel }) {
|
||||||
|
const dateien = ziel.dateien ?? [];
|
||||||
|
return (
|
||||||
|
<div className="border-border bg-bg rounded-box flex flex-col gap-3 border p-4">
|
||||||
|
<p className="text-muted font-mono text-[12.5px] break-all">
|
||||||
|
{ziel.zielbenutzer}@{ziel.hostName} · Private Key, ~/.ssh/config, known_hosts
|
||||||
|
</p>
|
||||||
|
<ul className="flex flex-col gap-1 font-mono text-[12.5px]">
|
||||||
|
{dateien
|
||||||
|
.filter((d) => d.art === "privateKey" || d.art === "publicKey")
|
||||||
|
.map((d) => (
|
||||||
|
<li
|
||||||
|
key={d.pfad}
|
||||||
|
className={
|
||||||
|
d.aktion === "keine" ? "text-muted" : d.aktion === "entfernen" ? "text-danger" : "text-ok"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{d.aktion === "keine" ? " " : d.aktion === "entfernen" ? "−" : "+"} {d.pfad} –{" "}
|
||||||
|
{AKTION_TEXT[d.aktion]}
|
||||||
|
{d.art === "privateKey" && d.aktion !== "keine" && d.aktion !== "entfernen"
|
||||||
|
? " (Inhalt wird nicht angezeigt)"
|
||||||
|
: ""}
|
||||||
|
{d.hinweis ? ` · ${d.hinweis}` : ""}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{dateien
|
||||||
|
.filter((d) => (d.art === "config" || d.art === "knownHosts") && d.aktion !== "keine" && d.diff)
|
||||||
|
.map((d) => (
|
||||||
|
<DiffBox key={d.pfad} datei={`${ziel.zielbenutzer}@${ziel.hostName}:${d.pfad}`} zeilen={d.diff!} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
|
||||||
|
import {
|
||||||
|
privateEntfernenAktion,
|
||||||
|
publicEntfernenAktion,
|
||||||
|
verbindungTestenAktion,
|
||||||
|
} from "@/server/actions/verwendungen";
|
||||||
|
|
||||||
|
/** Je Eintrag: Entfernen vormerken, bei Private Keys mit Alias den Verbindungstest (#016/#017/#020). */
|
||||||
|
export function EintragAktionen({
|
||||||
|
art,
|
||||||
|
verwendungId,
|
||||||
|
schluesselId,
|
||||||
|
darfEntfernen,
|
||||||
|
testbar,
|
||||||
|
}: {
|
||||||
|
art: "public" | "privat";
|
||||||
|
verwendungId: string;
|
||||||
|
schluesselId: string;
|
||||||
|
darfEntfernen: boolean;
|
||||||
|
testbar: boolean;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [fehler, setFehler] = useState<string>();
|
||||||
|
const [laeuft, starten] = useTransition();
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-start gap-2">
|
||||||
|
{testbar && (
|
||||||
|
<VorgangsKnopf
|
||||||
|
klein
|
||||||
|
aktion={verbindungTestenAktion.bind(null, verwendungId)}
|
||||||
|
beschriftung="Verbindung testen"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{darfEntfernen && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={laeuft}
|
||||||
|
title="Zuweisung entfernen"
|
||||||
|
aria-label="Zuweisung entfernen"
|
||||||
|
onClick={() =>
|
||||||
|
starten(async () => {
|
||||||
|
const antwort = await (art === "public" ? publicEntfernenAktion : privateEntfernenAktion)(
|
||||||
|
verwendungId,
|
||||||
|
schluesselId,
|
||||||
|
);
|
||||||
|
if (antwort.fehler) setFehler(antwort.fehler);
|
||||||
|
else router.refresh();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="text-muted hover:text-danger hover:bg-danger-soft rounded-feld flex size-11 items-center justify-center"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" aria-hidden />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{fehler && <p className="text-danger text-[13px]">{fehler}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Ankreuzfeld, Button, Feld, Formularfehler } from "@/components/ui";
|
||||||
|
import { Auswahlliste } from "@/components/ui/Feld";
|
||||||
|
import { useFormularAktion } from "@/components/hosts/useFormularAktion";
|
||||||
|
import { privateZuweisenAktion } from "@/server/actions/verwendungen";
|
||||||
|
import type { ZielAuswahl } from "@/server/queries/ziele";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Private Key auf einen Quell-Host legen (#017), optional mit ~/.ssh/config-Eintrag und dem
|
||||||
|
* Host-Key des Ziels in known_hosts – das, was ein Cronjob auf dem Quell-Host braucht.
|
||||||
|
*/
|
||||||
|
export function PrivateZuweisenFormular({
|
||||||
|
schluesselId,
|
||||||
|
slug,
|
||||||
|
auswahl,
|
||||||
|
passphraseGeschuetzt,
|
||||||
|
beiErfolg,
|
||||||
|
}: {
|
||||||
|
schluesselId: string;
|
||||||
|
slug: string;
|
||||||
|
auswahl: ZielAuswahl & { hostsDetails: { id: string; name: string; adresse: string; port: number }[] };
|
||||||
|
passphraseGeschuetzt: boolean;
|
||||||
|
beiErfolg?: () => void;
|
||||||
|
}) {
|
||||||
|
const [mitConfig, setMitConfig] = useState(true);
|
||||||
|
const [zielHostId, setZielHostId] = useState("");
|
||||||
|
const ziel = auswahl.hostsDetails.find((h) => h.id === zielHostId);
|
||||||
|
const { zustand, absenden, laeuft } = useFormularAktion(async (vorher, formular) => {
|
||||||
|
const ergebnis = await privateZuweisenAktion(vorher, formular);
|
||||||
|
if (!ergebnis.fehler && !ergebnis.feldfehler) beiErfolg?.();
|
||||||
|
return ergebnis;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={absenden} className="flex flex-col gap-4">
|
||||||
|
<input type="hidden" name="schluesselId" value={schluesselId} />
|
||||||
|
<Formularfehler text={zustand.fehler} />
|
||||||
|
{passphraseGeschuetzt && (
|
||||||
|
<p className="border-border-strong text-accent rounded-box border px-3 py-2.5 text-[13.5px]">
|
||||||
|
▲ Dieser Private Key ist passphrasengeschützt – für automatische Anmeldungen (Host-zu-Host)
|
||||||
|
ungeeignet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Auswahlliste
|
||||||
|
beschriftung="Quell-Host (bekommt den Private Key)"
|
||||||
|
name="hostId"
|
||||||
|
defaultValue=""
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Host wählen …
|
||||||
|
</option>
|
||||||
|
{auswahl.hosts.map((h) => (
|
||||||
|
<option key={h.id} value={h.id}>
|
||||||
|
{h.name}
|
||||||
|
{h.bereit ? "" : " (noch kein Bootstrap)"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld
|
||||||
|
beschriftung="Zielbenutzer"
|
||||||
|
name="zielbenutzer"
|
||||||
|
defaultValue="root"
|
||||||
|
mono
|
||||||
|
required
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Pfad"
|
||||||
|
name="pfad"
|
||||||
|
defaultValue={`~/.ssh/id_ed25519_${slug}`}
|
||||||
|
mono
|
||||||
|
required
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Ankreuzfeld
|
||||||
|
beschriftung="Eintrag in ~/.ssh/config anlegen"
|
||||||
|
erklaerung="Dann genügt auf dem Quell-Host ein einfaches „ssh <Alias>“."
|
||||||
|
checked={mitConfig}
|
||||||
|
onChange={(e) => setMitConfig(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{mitConfig && (
|
||||||
|
<div className="border-border flex flex-col gap-4 border-l-2 pl-4">
|
||||||
|
<Auswahlliste
|
||||||
|
beschriftung="Ziel-Host (für known_hosts)"
|
||||||
|
name="zielHostId"
|
||||||
|
value={zielHostId}
|
||||||
|
onChange={(e) => setZielHostId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">– kein verwalteter Host –</option>
|
||||||
|
{auswahl.hostsDetails.map((h) => (
|
||||||
|
<option key={h.id} value={h.id}>
|
||||||
|
{h.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld
|
||||||
|
key={`alias-${zielHostId}`}
|
||||||
|
beschriftung="Alias"
|
||||||
|
name="alias"
|
||||||
|
defaultValue={ziel ? ziel.name : ""}
|
||||||
|
mono
|
||||||
|
required
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
key={`hn-${zielHostId}`}
|
||||||
|
beschriftung="HostName"
|
||||||
|
name="hostName"
|
||||||
|
defaultValue={ziel?.adresse ?? ""}
|
||||||
|
mono
|
||||||
|
required
|
||||||
|
autoComplete="off"
|
||||||
|
hinweis="So, wie der Quell-Host das Ziel erreicht."
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Benutzer am Ziel"
|
||||||
|
name="sshBenutzer"
|
||||||
|
defaultValue="root"
|
||||||
|
mono
|
||||||
|
required
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
key={`port-${zielHostId}`}
|
||||||
|
beschriftung="Port"
|
||||||
|
name="port"
|
||||||
|
type="number"
|
||||||
|
defaultValue={ziel?.port ?? 22}
|
||||||
|
mono
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Auswahlliste beschriftung="Beim Widerruf" name="beimWiderruf" defaultValue="LOESCHEN">
|
||||||
|
<option value="LOESCHEN">Datei löschen</option>
|
||||||
|
<option value="UMBENENNEN">Datei umbenennen (…sshkv-widerrufen…)</option>
|
||||||
|
</Auswahlliste>
|
||||||
|
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||||||
|
{laeuft ? "Speichere …" : "Zuweisen"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,13 +4,17 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { z } from "@/lib/validierung";
|
import { z } from "@/lib/validierung";
|
||||||
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
import { akteurVon, textfeld, type FormularZustand } from "@/server/actions/formular";
|
import { akteurVon, textfeld, type FormularZustand } from "@/server/actions/formular";
|
||||||
|
import { darf } from "@/server/auth/rechte";
|
||||||
import {
|
import {
|
||||||
VerwendungsFehler,
|
VerwendungsFehler,
|
||||||
ausrollenStarten,
|
ausrollenStarten,
|
||||||
|
privateVerwendungAnlegen,
|
||||||
|
privateVerwendungEntfernen,
|
||||||
publicVerwendungAnlegen,
|
publicVerwendungAnlegen,
|
||||||
publicVerwendungEntfernen,
|
publicVerwendungEntfernen,
|
||||||
trockenlaufStarten,
|
trockenlaufStarten,
|
||||||
} from "@/server/services/verwendungen";
|
} from "@/server/services/verwendungen";
|
||||||
|
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||||
|
|
||||||
/** Public-Key-Zuweisungen und Ausrollen (#016). Duenn: Recht, Eingabe, Service. */
|
/** Public-Key-Zuweisungen und Ausrollen (#016). Duenn: Recht, Eingabe, Service. */
|
||||||
|
|
||||||
@@ -82,6 +86,8 @@ export async function trockenlaufAktion(auswahl: AusrollAuswahl): Promise<Vorgan
|
|||||||
? { schluesselId: id.parse(auswahl.schluesselId) }
|
? { schluesselId: id.parse(auswahl.schluesselId) }
|
||||||
: { hostId: id.parse(auswahl.hostId) },
|
: { hostId: id.parse(auswahl.hostId) },
|
||||||
akteurVon(benutzer),
|
akteurVon(benutzer),
|
||||||
|
// Private-Key-Ziele nur fuer Admins – ein Operator rollt so nie einen Private Key aus.
|
||||||
|
darf(benutzer.rolle, "privateKeyAusrollen"),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
@@ -99,3 +105,69 @@ export async function ausrollenAktion(trockenlaufId: string): Promise<VorgangsAn
|
|||||||
return alsAntwort(fehler);
|
return alsAntwort(fehler);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Private Key einem Quell-Host zuweisen (#017) – nur Admins. */
|
||||||
|
export async function privateZuweisenAktion(
|
||||||
|
_vorher: FormularZustand,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<FormularZustand> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("privateKeyAusrollen");
|
||||||
|
const schluesselId = z.string().min(1).max(64).parse(textfeld(formular, "schluesselId"));
|
||||||
|
const alias = (textfeld(formular, "alias") ?? "").trim();
|
||||||
|
const zielHostId = textfeld(formular, "zielHostId");
|
||||||
|
await privateVerwendungAnlegen(
|
||||||
|
{
|
||||||
|
schluesselId,
|
||||||
|
hostId: textfeld(formular, "hostId") ?? "",
|
||||||
|
zielbenutzer: (textfeld(formular, "zielbenutzer") ?? "").trim(),
|
||||||
|
pfad: textfeld(formular, "pfad") ?? "",
|
||||||
|
sshConfig: alias
|
||||||
|
? {
|
||||||
|
alias,
|
||||||
|
hostName: (textfeld(formular, "hostName") ?? "").trim(),
|
||||||
|
benutzer: (textfeld(formular, "sshBenutzer") ?? "").trim(),
|
||||||
|
port: Number(textfeld(formular, "port") ?? "22"),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
knownHostsFuer: zielHostId ? [zielHostId] : [],
|
||||||
|
beimWiderruf: textfeld(formular, "beimWiderruf") === "UMBENENNEN" ? "UMBENENNEN" : "LOESCHEN",
|
||||||
|
},
|
||||||
|
akteurVon(benutzer),
|
||||||
|
);
|
||||||
|
revalidatePath(`/schluessel/${schluesselId}`);
|
||||||
|
return {};
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsAntwort(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function privateEntfernenAktion(
|
||||||
|
verwendungId: string,
|
||||||
|
schluesselId: string,
|
||||||
|
): Promise<{ fehler?: string }> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("privateKeyAusrollen");
|
||||||
|
await privateVerwendungEntfernen(z.string().min(1).max(64).parse(verwendungId), akteurVon(benutzer));
|
||||||
|
revalidatePath(`/schluessel/${schluesselId}`);
|
||||||
|
return {};
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsAntwort(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verbindungstest A → B (#020). Harmlos, aber nur fuer Rollen, die Hosts verwalten. */
|
||||||
|
export async function verbindungTestenAktion(privatVerwendungId: string): Promise<VorgangsAntwort> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("hostsVerwalten");
|
||||||
|
return {
|
||||||
|
vorgangId: await vorgangEinreihen({
|
||||||
|
art: "VERBINDUNGSTEST",
|
||||||
|
angefordertVonId: benutzer.id,
|
||||||
|
eingabe: { privatVerwendungId: z.string().min(1).max(64).parse(privatVerwendungId) },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsAntwort(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export async function zielAuswahl() {
|
|||||||
const [hosts, gruppen] = await Promise.all([
|
const [hosts, gruppen] = await Promise.all([
|
||||||
prisma.host.findMany({
|
prisma.host.findMany({
|
||||||
where: { archiviertAm: null },
|
where: { archiviertAm: null },
|
||||||
select: { id: true, name: true, bootstrapAm: true },
|
select: { id: true, name: true, bootstrapAm: true, adresse: true, port: true },
|
||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
}),
|
}),
|
||||||
prisma.hostgruppe.findMany({
|
prisma.hostgruppe.findMany({
|
||||||
@@ -15,6 +15,7 @@ export async function zielAuswahl() {
|
|||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
hosts: hosts.map((h) => ({ id: h.id, name: h.name, bereit: h.bootstrapAm !== null })),
|
hosts: hosts.map((h) => ({ id: h.id, name: h.name, bereit: h.bootstrapAm !== null })),
|
||||||
|
hostsDetails: hosts.map((h) => ({ id: h.id, name: h.name, adresse: h.adresse, port: h.port })),
|
||||||
gruppen: gruppen.map((g) => ({ id: g.id, name: g.name, mitglieder: g._count.mitglieder })),
|
gruppen: gruppen.map((g) => ({ id: g.id, name: g.name, mitglieder: g._count.mitglieder })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ export async function publicVerwendungEntfernen(id: string, akteur: Akteur): Pro
|
|||||||
export async function trockenlaufStarten(
|
export async function trockenlaufStarten(
|
||||||
auswahl: { schluesselId?: string; hostId?: string; verwendungIds?: string[]; ziele?: Ziel[] },
|
auswahl: { schluesselId?: string; hostId?: string; verwendungIds?: string[]; ziele?: Ziel[] },
|
||||||
akteur: Akteur,
|
akteur: Akteur,
|
||||||
|
/** Private-Key-Ziele nur fuer Admins (Freigabe Frage 3) – der Aufrufer entscheidet per darf(). */
|
||||||
|
mitPrivat = false,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const ziele = auswahl.ziele ?? [
|
const ziele = auswahl.ziele ?? [
|
||||||
...(await zieleVonVerwendungen({
|
...(await zieleVonVerwendungen({
|
||||||
@@ -128,7 +130,7 @@ export async function trockenlaufStarten(
|
|||||||
...(auswahl.verwendungIds ? { ids: auswahl.verwendungIds } : {}),
|
...(auswahl.verwendungIds ? { ids: auswahl.verwendungIds } : {}),
|
||||||
})),
|
})),
|
||||||
// Private Keys nur, wenn nicht gezielt einzelne Public-Verwendungen gemeint sind.
|
// Private Keys nur, wenn nicht gezielt einzelne Public-Verwendungen gemeint sind.
|
||||||
...(auswahl.verwendungIds
|
...(auswahl.verwendungIds || !mitPrivat
|
||||||
? []
|
? []
|
||||||
: await zieleVonPrivatVerwendungen({
|
: await zieleVonPrivatVerwendungen({
|
||||||
...(auswahl.schluesselId ? { schluesselId: auswahl.schluesselId } : {}),
|
...(auswahl.schluesselId ? { schluesselId: auswahl.schluesselId } : {}),
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
import "dotenv/config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kern-Anwendungsfall im Browser (#012, #016, #017, #020), gebauter Stand + Worker:
|
||||||
|
* Schluessel erzeugen → Private Key auf Ziel A (mit ~/.ssh/config und known_hosts) →
|
||||||
|
* Public Key auf Ziel B → Dry-Run → anwenden → Verbindung von A nach B testen.
|
||||||
|
* Beide Hosts heissen zz-e2e-… und werden am Ende archiviert.
|
||||||
|
*/
|
||||||
|
const ROOT_PASSWORT = process.env["SSHKV_TEST_ROOT_PASSWORT"] || "sshkv-test";
|
||||||
|
const PORT = {
|
||||||
|
a: (process.env["SSHKV_TEST_ZIEL_A"] ?? "127.0.0.1:2221").split(":")[1] ?? "2221",
|
||||||
|
b: (process.env["SSHKV_TEST_ZIEL_B"] ?? "127.0.0.1:2222").split(":")[1] ?? "2222",
|
||||||
|
};
|
||||||
|
|
||||||
|
test.setTimeout(240_000);
|
||||||
|
|
||||||
|
function aufZiel(ziel: "ziel-a" | "ziel-b", befehl: string): string {
|
||||||
|
return execFileSync(
|
||||||
|
"docker",
|
||||||
|
["compose", "-f", "docker-compose.test.yml", "exec", "-T", ziel, "sh", "-c", befehl],
|
||||||
|
{
|
||||||
|
encoding: "utf8",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hostEinrichten(page: Page, name: string, port: string): Promise<void> {
|
||||||
|
await page.goto("/hosts/neu");
|
||||||
|
await page.getByLabel("Name").fill(name);
|
||||||
|
await page.getByLabel("Adresse").fill("127.0.0.1");
|
||||||
|
await page.getByLabel("Port").fill(port);
|
||||||
|
await page.getByRole("button", { name: "Host anlegen" }).click();
|
||||||
|
await page.getByRole("button", { name: "Host-Key abrufen" }).click();
|
||||||
|
await page.getByRole("button", { name: "Fingerprint stimmt – vertrauen" }).click({ timeout: 30_000 });
|
||||||
|
await page.getByLabel("Passwort", { exact: true }).fill(ROOT_PASSWORT);
|
||||||
|
await page.getByRole("button", { name: "Zugang einrichten" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "Verbindung prüfen" })).toBeVisible({ timeout: 60_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archivieren(page: Page, name: string): Promise<void> {
|
||||||
|
await page.goto("/hosts");
|
||||||
|
await page.getByRole("link", { name }).first().click();
|
||||||
|
await page.getByRole("button", { name: "Host archivieren" }).click();
|
||||||
|
await page.getByRole("dialog").getByRole("button", { name: "Archivieren", exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(/\/hosts$/);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeAll(() => {
|
||||||
|
const reset = "userdel -r sshkv 2>/dev/null; rm -f /etc/sudoers.d/sshkv; rm -rf /root/.ssh; true";
|
||||||
|
aufZiel("ziel-a", reset);
|
||||||
|
aufZiel("ziel-b", reset);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Backup A → B: erzeugen, ausrollen, Verbindung testen", async ({ page }) => {
|
||||||
|
const kennung = Date.now().toString(36);
|
||||||
|
const hostA = `zz-e2e-a-${kennung}`;
|
||||||
|
const hostB = `zz-e2e-b-${kennung}`;
|
||||||
|
const zweck = `zz-e2e Backup ${kennung}`;
|
||||||
|
|
||||||
|
await page.goto("/anmelden");
|
||||||
|
await page.getByRole("link", { name: /Mit Mock-OIDC anmelden/ }).click();
|
||||||
|
await page.locator('button[data-rolle="admin"]').click();
|
||||||
|
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
|
||||||
|
|
||||||
|
await hostEinrichten(page, hostA, PORT.a);
|
||||||
|
await hostEinrichten(page, hostB, PORT.b);
|
||||||
|
|
||||||
|
// 1. Schluessel erzeugen
|
||||||
|
await page.goto("/schluessel/neu");
|
||||||
|
await page.getByLabel("Zweck").fill(zweck);
|
||||||
|
await page.getByRole("button", { name: "Schlüssel erzeugen" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: zweck })).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
// 2a. Private Key auf A, mit config-Eintrag und Host-Key von B
|
||||||
|
await page.getByRole("button", { name: "Ausrollen" }).first().click();
|
||||||
|
const privat = page.getByRole("dialog", { name: "Private Key ausrollen" });
|
||||||
|
await privat.getByLabel("Quell-Host (bekommt den Private Key)").selectOption({ label: hostA });
|
||||||
|
await privat.getByLabel("Ziel-Host (für known_hosts)").selectOption({ label: hostB });
|
||||||
|
await privat.getByRole("textbox", { name: "Alias" }).fill("zu-b");
|
||||||
|
// A erreicht B im Docker-Netz unter dem Dienstnamen auf Port 22.
|
||||||
|
await privat.getByRole("textbox", { name: "HostName" }).fill("ziel-b");
|
||||||
|
await privat.getByRole("spinbutton", { name: "Port" }).fill("22");
|
||||||
|
await privat.getByRole("button", { name: "Zuweisen" }).click();
|
||||||
|
await expect(privat).toBeHidden();
|
||||||
|
await expect(page.getByText(`root@${hostA}`)).toBeVisible();
|
||||||
|
|
||||||
|
// 2b. Public Key auf B
|
||||||
|
await page.getByRole("button", { name: "Ausrollen" }).nth(1).click();
|
||||||
|
const oeffentlich = page.getByRole("dialog", { name: "Public Key zuweisen" });
|
||||||
|
await oeffentlich.getByLabel("Ziel", { exact: true }).selectOption({ label: hostB });
|
||||||
|
await oeffentlich.getByLabel(/Einschränkungen/).fill("no-pty,no-agent-forwarding");
|
||||||
|
await oeffentlich.getByRole("button", { name: "Zuweisen" }).click();
|
||||||
|
await expect(oeffentlich).toBeHidden();
|
||||||
|
await expect(page.getByText(`root@${hostB}`)).toBeVisible();
|
||||||
|
|
||||||
|
// 3. Dry-Run und anwenden
|
||||||
|
await page.getByRole("button", { name: "Dry-Run starten" }).click();
|
||||||
|
await expect(page.getByText(`root@${hostB}:/root/.ssh/authorized_keys`)).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.getByText("Host zu-b").first()).toBeVisible();
|
||||||
|
// Kein Private Key im Browser.
|
||||||
|
expect(await page.content()).not.toContain("PRIVATE KEY");
|
||||||
|
await page.screenshot({ path: "docs/anleitung/bilder/kern-dry-run-desktop.png", fullPage: true });
|
||||||
|
await page.getByRole("button", { name: /Änderung anwenden/ }).click();
|
||||||
|
await expect(page.getByText("Ausgerollt.")).toBeVisible({ timeout: 60_000 });
|
||||||
|
|
||||||
|
// 4. Verbindungstest von A nach B
|
||||||
|
await page.getByRole("button", { name: "Verbindung testen" }).click();
|
||||||
|
await expect(page.getByText("Erledigt.")).toBeVisible({ timeout: 30_000 });
|
||||||
|
expect(aufZiel("ziel-a", "ssh -o BatchMode=yes zu-b id -un").trim()).toBe("root");
|
||||||
|
await page.screenshot({ path: "docs/anleitung/bilder/kern-ausgerollt-desktop.png", fullPage: true });
|
||||||
|
|
||||||
|
await archivieren(page, hostA);
|
||||||
|
await archivieren(page, hostB);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user