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:
@@ -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
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user