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