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
@@ -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={