feat(schluessel): Zuweisen und Ausrollen auf der Schluesselseite (#017)

Auf der Seite eines Schluessels: Public Key einem Host oder einer
Gruppe zuweisen, Private Key (nur Admin) auf einen Quell-Host legen -
mit ~/.ssh/config-Eintrag und dem Host-Key des Ziels in known_hosts -,
Dry-Run fuer alle Ziele, anwenden und die Verbindung von A nach B
testen. Der Kern-Anwendungsfall laeuft damit im Browser durch (E2E).

Der Dry-Run nimmt Private-Key-Ziele nur fuer Admins auf: sonst koennte
ein Operator einen vorbereiteten Private Key ausrollen lassen. Die
Vorschau zeigt je Datei die Aktion; der Inhalt eines Private Keys
erreicht den Browser nie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 11:05:03 +02:00
co-authored by Claude Opus 5.5
parent 68136e8f07
commit 12603eb145
15 changed files with 660 additions and 39 deletions
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";
/**
* Platzhalter fuer „+ Ausrollen“ in der Karte „Private Key ausgerollt auf“ (#012).
* Inhalt folgt mit #017 (Private Key auf einen Host legen); bis dahin bewusst leer.
*/
export function PrivateAusrollenAktion(props: { schluesselId: string; darf: boolean }): null {
void props;
return null;
import { AusrollDialog } from "@/components/ausrollen/AusrollDialog";
import { PrivateZuweisenFormular } from "@/components/ausrollen/PrivateZuweisenFormular";
import type { ZielAuswahl } from "@/server/queries/ziele";
/** „+ Ausrollen“ in der Karte „Private Key ausgerollt auf“ (#017) – nur Admins. */
export function PrivateAusrollenAktion({
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";
/**
* Platzhalter fuer „+ Ausrollen“ in der Karte „Public Key ausgerollt auf“ (#012).
* Inhalt folgt mit #016 (Zuweisung, Dry-Run, Ausrollen); bis dahin bewusst leer.
*/
export function PublicAusrollenAktion(props: { schluesselId: string; darf: boolean }): null {
void props;
return null;
import { AusrollDialog } from "@/components/ausrollen/AusrollDialog";
import { PublicZuweisenFormular } from "@/components/ausrollen/PublicZuweisenFormular";
import type { ZielAuswahl } from "@/server/queries/ziele";
/** „+ Ausrollen“ in der Karte „Public Key ausgerollt auf“ (#016). */
export function PublicAusrollenAktion({
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>
);
}
+82 -21
View File
@@ -16,6 +16,9 @@ import { datum, datumZeit } from "@/lib/format";
import { darf } from "@/server/auth/rechte";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
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 { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen";
import { PublicAusrollenAktion } from "./PublicAusrollenAktion";
@@ -40,7 +43,15 @@ const ZUSTAND: Record<string, { status: HostStatus; text: string }> = {
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({
titel,
@@ -67,21 +78,25 @@ function AusrollKarte({
const z = ZUSTAND[e.zustand] ?? { status: "unbekannt" as const, text: e.zustand };
return (
<li key={e.id}>
<InnereBox className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<p className="text-text font-mono text-[13.5px] font-medium break-all">
{e.href ? (
<Link href={e.href} className="hover:text-accent transition-colors">
{e.ziel}
</Link>
) : (
e.ziel
)}
</p>
<p className="text-muted mt-1 font-mono text-[12.5px] break-all">{e.pfad}</p>
{e.zusatz && <p className="text-muted font-mono text-[12.5px] break-all">{e.zusatz}</p>}
<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">
<p className="text-text font-mono text-[13.5px] font-medium break-all">
{e.href ? (
<Link href={e.href} className="hover:text-accent transition-colors">
{e.ziel}
</Link>
) : (
e.ziel
)}
</p>
<p className="text-muted mt-1 font-mono text-[12.5px] break-all">{e.pfad}</p>
{e.zusatz && <p className="text-muted font-mono text-[12.5px] break-all">{e.zusatz}</p>}
</div>
<Statuspunkt status={z.status} text={z.text} className="shrink-0 sm:pt-0.5" />
</div>
<Statuspunkt status={z.status} text={z.text} className="shrink-0 sm:pt-0.5" />
{/* Aktionen unter dem Eintrag, damit lange Hostnamen nicht umbrechen (#016). */}
{e.aktionen}
</InnereBox>
</li>
);
@@ -92,7 +107,7 @@ function AusrollKarte({
);
}
function privateEintraege(s: SchluesselDetail): Eintrag[] {
function privateEintraege(s: SchluesselDetail, rechte: Rechte): Eintrag[] {
return s.privateVerwendungen.map((v) => {
const config =
v.sshConfig && typeof v.sshConfig === "object" ? (v.sshConfig as { alias?: unknown }) : null;
@@ -103,11 +118,22 @@ function privateEintraege(s: SchluesselDetail): Eintrag[] {
pfad: v.pfad,
...(typeof config?.alias === "string" ? { zusatz: `config: Host ${config.alias}` } : {}),
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) => ({
id: v.id,
ziel: v.host
@@ -121,6 +147,15 @@ function publicEintraege(s: SchluesselDetail): Eintrag[] {
pfad: "~/.ssh/authorized_keys",
...(v.optionen ? { zusatz: v.optionen } : {}),
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();
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 verwalten = darf(benutzer.rolle, "schluesselVerwalten");
// 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
titel="Private Key ausgerollt auf"
untertitel="Hosts, die sich mit diesem Schlüssel anmelden"
eintraege={privateEintraege(s)}
eintraege={privateEintraege(s, rechte)}
leer={
s.privatGespeichert
? "Der Private Key liegt noch auf keinem Host."
@@ -252,7 +293,10 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
aktion={
<PrivateAusrollenAktion
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
titel="Public Key ausgerollt auf"
untertitel="Konten, die diesen Schlüssel annehmen"
eintraege={publicEintraege(s)}
eintraege={publicEintraege(s, rechte)}
leer={
personenKey
? "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={
<PublicAusrollenAktion
schluesselId={s.id}
darf={aktiv && darf(benutzer.rolle, "publicKeyAusrollen")}
darf={aktiv && rechte.public}
{...(auswahl ? { auswahl } : {})}
/>
}
/>
</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
titel="Public Key"
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>
</>
);
}
+61 -2
View File
@@ -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
* 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 = {
art?: "public" | "privat";
dateien?: DateiPlan[];
hostName: string;
zielbenutzer: 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]">
{ziel.zielbenutzer}@{ziel.hostName}: {ziel.fehler}
</p>
) : ziel.art === "privat" ? (
<PrivatPlanAnzeige ziel={ziel} />
) : (
<DiffBox
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">
<label className="text-muted flex min-h-11 items-center gap-3 text-[14px]">
<input type="checkbox" checked disabled className="accent-accent size-5" />
Backup jeder Datei anlegen (
<code className="font-mono text-[12.5px]">authorized_keys.sshkv.bak.…</code>)
Backup jeder geänderten Datei anlegen (
<code className="font-mono text-[12.5px]">….sshkv.bak.…</code>) – außer bei Private Keys, von
denen keine zweite Kopie liegen bleiben soll
</label>
<div className="flex flex-wrap gap-3">
<Button
@@ -131,3 +144,49 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of
</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>
);
}
+72
View File
@@ -4,13 +4,17 @@ import { revalidatePath } from "next/cache";
import { z } from "@/lib/validierung";
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { akteurVon, textfeld, type FormularZustand } from "@/server/actions/formular";
import { darf } from "@/server/auth/rechte";
import {
VerwendungsFehler,
ausrollenStarten,
privateVerwendungAnlegen,
privateVerwendungEntfernen,
publicVerwendungAnlegen,
publicVerwendungEntfernen,
trockenlaufStarten,
} from "@/server/services/verwendungen";
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
/** 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) }
: { hostId: id.parse(auswahl.hostId) },
akteurVon(benutzer),
// Private-Key-Ziele nur fuer Admins – ein Operator rollt so nie einen Private Key aus.
darf(benutzer.rolle, "privateKeyAusrollen"),
),
};
} catch (fehler) {
@@ -99,3 +105,69 @@ export async function ausrollenAktion(trockenlaufId: string): Promise<VorgangsAn
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);
}
}
+2 -1
View File
@@ -5,7 +5,7 @@ export async function zielAuswahl() {
const [hosts, gruppen] = await Promise.all([
prisma.host.findMany({
where: { archiviertAm: null },
select: { id: true, name: true, bootstrapAm: true },
select: { id: true, name: true, bootstrapAm: true, adresse: true, port: true },
orderBy: { name: "asc" },
}),
prisma.hostgruppe.findMany({
@@ -15,6 +15,7 @@ export async function zielAuswahl() {
]);
return {
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 })),
};
}
+3 -1
View File
@@ -120,6 +120,8 @@ export async function publicVerwendungEntfernen(id: string, akteur: Akteur): Pro
export async function trockenlaufStarten(
auswahl: { schluesselId?: string; hostId?: string; verwendungIds?: string[]; ziele?: Ziel[] },
akteur: Akteur,
/** Private-Key-Ziele nur fuer Admins (Freigabe Frage 3) – der Aufrufer entscheidet per darf(). */
mitPrivat = false,
): Promise<string> {
const ziele = auswahl.ziele ?? [
...(await zieleVonVerwendungen({
@@ -128,7 +130,7 @@ export async function trockenlaufStarten(
...(auswahl.verwendungIds ? { ids: auswahl.verwendungIds } : {}),
})),
// Private Keys nur, wenn nicht gezielt einzelne Public-Verwendungen gemeint sind.
...(auswahl.verwendungIds
...(auswahl.verwendungIds || !mitPrivat
? []
: await zieleVonPrivatVerwendungen({
...(auswahl.schluesselId ? { schluesselId: auswahl.schluesselId } : {}),
+117
View File
@@ -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);
});