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
@@ -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>
);
}