feat(hosts): Schluessel auf dem Host anzeigen und ausrollen (#016)

Die Hostseite listet, welche Public und Private Keys sshkv dort
verwaltet - direkt oder ueber eine Hostgruppe - mit Zustand als Punkt
und Text. Ein Dry-Run je Host zeigt das Diff aller Benutzer dieses
Hosts und rollt nach Bestaetigung aus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 10:19:27 +02:00
co-authored by Claude Opus 5.5
parent f66d87ad11
commit 30f271b23a
7 changed files with 151 additions and 13 deletions
+61 -3
View File
@@ -18,6 +18,9 @@ import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { hostDetail, type HostDetail } from "@/server/queries/hosts"; import { hostDetail, type HostDetail } from "@/server/queries/hosts";
import { BootstrapAktionen } from "./BootstrapAktionen"; import { BootstrapAktionen } from "./BootstrapAktionen";
import { sshdHinweise } from "@/lib/sshd-hinweise"; import { sshdHinweise } from "@/lib/sshd-hinweise";
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
import { VerwendungsZustand } from "@/components/ausrollen/VerwendungsZustand";
import { verwendungenAufHost } from "@/server/queries/ziele";
import { HostKeyAktionen } from "./HostKeyAktionen"; import { HostKeyAktionen } from "./HostKeyAktionen";
import { HostKeyEntscheidung } from "./HostKeyEntscheidung"; import { HostKeyEntscheidung } from "./HostKeyEntscheidung";
import { bootstrapBefehlFuer } from "@/server/queries/bootstrap"; import { bootstrapBefehlFuer } from "@/server/queries/bootstrap";
@@ -95,6 +98,8 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri
const archiviert = host.archiviertAm !== null; const archiviert = host.archiviertAm !== null;
const verwalten = darf(benutzer.rolle, "hostsVerwalten") && !archiviert; const verwalten = darf(benutzer.rolle, "hostsVerwalten") && !archiviert;
const darfEntscheiden = darf(benutzer.rolle, "hostKeyEntscheiden") && !archiviert; const darfEntscheiden = darf(benutzer.rolle, "hostKeyEntscheiden") && !archiviert;
const darfAusrollen = darf(benutzer.rolle, "publicKeyAusrollen") && !archiviert;
const verwendungen = await verwendungenAufHost(host.id);
const jetzt = new Date(); const jetzt = new Date();
return ( return (
@@ -217,7 +222,7 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri
<Karte <Karte
titel="Schlüssel auf diesem Host" titel="Schlüssel auf diesem Host"
untertitel={`${host.publicVerwendungen} Public-Key-Verwendungen · ${host.privateVerwendungen} Private-Key-Verwendungen`} untertitel="Public Keys in authorized_keys und Private Keys, die sshkv hier verwaltet"
> >
{host.zielkonten.length > 0 && ( {host.zielkonten.length > 0 && (
<p className="text-muted mb-4 text-[14px]"> <p className="text-muted mb-4 text-[14px]">
@@ -230,11 +235,64 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri
))} ))}
</p> </p>
)} )}
{verwendungen.oeffentlich.length === 0 && verwendungen.privat.length === 0 ? (
<Leerzustand <Leerzustand
icon={<KeyRound className="size-5" />} icon={<KeyRound className="size-5" />}
titel="Folgt mit #016" titel="Noch keine Schlüssel zugewiesen"
text="Hier erscheinen die Public und Private Keys, die sshkv auf diesem Host verwaltet." text="Zuweisen geht über die Seite eines Schlüssels („+ Ausrollen“)."
/> />
) : (
<ul className="flex flex-col">
{verwendungen.oeffentlich.map((v) => (
<li
key={v.id}
className="border-border flex flex-col gap-1 border-b py-3 first:pt-0 last:border-b-0 sm:flex-row sm:items-center sm:justify-between sm:gap-4"
>
<span className="min-w-0">
<a
href={`/schluessel/${v.schluessel.id}`}
className="text-text hover:text-accent font-medium"
>
{v.schluessel.name}
</a>
<span className="text-muted block font-mono text-[12.5px] break-all">
Public · {v.zielbenutzer}@{host.name}
{v.hostgruppe ? ` · über ${v.hostgruppe.name}` : ""}
{v.optionen ? ` · ${v.optionen}` : ""}
</span>
</span>
<VerwendungsZustand zustand={v.zustand} />
</li>
))}
{verwendungen.privat.map((v) => (
<li
key={v.id}
className="border-border flex flex-col gap-1 border-b py-3 first:pt-0 last:border-b-0 sm:flex-row sm:items-center sm:justify-between sm:gap-4"
>
<span className="min-w-0">
<a
href={`/schluessel/${v.schluessel.id}`}
className="text-text hover:text-accent font-medium"
>
{v.schluessel.name}
</a>
<span className="text-muted block font-mono text-[12.5px] break-all">
Private · {v.zielbenutzer}@{host.name}:{v.pfad}
</span>
</span>
<VerwendungsZustand zustand={v.zustand} />
</li>
))}
</ul>
)}
{darfAusrollen && verwendungen.oeffentlich.length > 0 && host.bootstrapAm && (
<div className="border-border mt-5 border-t pt-5">
<AusrollenPanel
auswahl={{ hostId: host.id }}
offen={verwendungen.oeffentlich.filter((v) => v.zustand !== "AUSGEROLLT").length}
/>
</div>
)}
</Karte> </Karte>
{verwalten && ( {verwalten && (
+3 -3
View File
@@ -6,7 +6,7 @@ import { useEffect, useState, useTransition } from "react";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { DiffBox } from "@/components/ui/DiffBox"; import { DiffBox } from "@/components/ui/DiffBox";
import { laeuftNoch, useVorgang } from "@/components/vorgang/useVorgang"; import { laeuftNoch, useVorgang } from "@/components/vorgang/useVorgang";
import { ausrollenAktion, trockenlaufAktion } from "@/server/actions/verwendungen"; import { ausrollenAktion, trockenlaufAktion, type AusrollAuswahl } from "@/server/actions/verwendungen";
import type { DiffZeile } from "@/lib/ssh/zeilen-diff"; import type { DiffZeile } from "@/lib/ssh/zeilen-diff";
/** /**
@@ -24,7 +24,7 @@ type PlanZiel = {
fehler?: string; fehler?: string;
}; };
export function AusrollenPanel({ schluesselId, offen }: { schluesselId: string; offen: number }) { export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; offen: number }) {
const router = useRouter(); const router = useRouter();
const [trockenlaufId, setTrockenlaufId] = useState<string>(); const [trockenlaufId, setTrockenlaufId] = useState<string>();
const [ausrollenId, setAusrollenId] = useState<string>(); const [ausrollenId, setAusrollenId] = useState<string>();
@@ -52,7 +52,7 @@ export function AusrollenPanel({ schluesselId, offen }: { schluesselId: string;
starten(async () => { starten(async () => {
setFehler(undefined); setFehler(undefined);
setAusrollenId(undefined); setAusrollenId(undefined);
const antwort = await trockenlaufAktion(schluesselId); const antwort = await trockenlaufAktion(auswahl);
if (antwort.fehler) setFehler(antwort.fehler); if (antwort.fehler) setFehler(antwort.fehler);
else setTrockenlaufId(antwort.vorgangId); else setTrockenlaufId(antwort.vorgangId);
}) })
@@ -0,0 +1,28 @@
import type { VerwendungsZustand as Zustand } from "@/generated/prisma/enums";
import { clsx } from "clsx";
/** Zustand einer Verwendung – immer Punkt und Text, nie nur Farbe (#016). */
const ANZEIGE: Record<Zustand, { text: string; klasse: string; punkt: string }> = {
GEPLANT: { text: "noch nicht ausgerollt", klasse: "text-accent", punkt: "○" },
AUSGEROLLT: { text: "synchron", klasse: "text-ok", punkt: "●" },
ABWEICHUNG: { text: "Drift", klasse: "text-danger", punkt: "●" },
ENTFERNEN: { text: "wird entfernt", klasse: "text-accent", punkt: "○" },
ENTFERNT: { text: "entfernt", klasse: "text-muted", punkt: "○" },
FEHLGESCHLAGEN: { text: "fehlgeschlagen", klasse: "text-danger", punkt: "●" },
};
export function VerwendungsZustand({ zustand, className }: { zustand: Zustand; className?: string }) {
const anzeige = ANZEIGE[zustand];
return (
<span
className={clsx(
"inline-flex items-center gap-1.5 text-[13px] whitespace-nowrap",
anzeige.klasse,
className,
)}
>
<span aria-hidden>{anzeige.punkt}</span>
{anzeige.text}
</span>
);
}
+7 -2
View File
@@ -70,12 +70,17 @@ export async function publicEntfernenAktion(
} }
} }
export async function trockenlaufAktion(schluesselId: string): Promise<VorgangsAntwort> { export type AusrollAuswahl = { readonly schluesselId: string } | { readonly hostId: string };
export async function trockenlaufAktion(auswahl: AusrollAuswahl): Promise<VorgangsAntwort> {
try { try {
const { benutzer } = await rechtErforderlich("publicKeyAusrollen"); const { benutzer } = await rechtErforderlich("publicKeyAusrollen");
const id = z.string().min(1).max(64);
return { return {
vorgangId: await trockenlaufStarten( vorgangId: await trockenlaufStarten(
{ schluesselId: z.string().min(1).max(64).parse(schluesselId) }, "schluesselId" in auswahl
? { schluesselId: id.parse(auswahl.schluesselId) }
: { hostId: id.parse(auswahl.hostId) },
akteurVon(benutzer), akteurVon(benutzer),
), ),
}; };
+33
View File
@@ -20,3 +20,36 @@ export async function zielAuswahl() {
} }
export type ZielAuswahl = Awaited<ReturnType<typeof zielAuswahl>>; export type ZielAuswahl = Awaited<ReturnType<typeof zielAuswahl>>;
/** Was sshkv auf einem Host verwaltet (#016): Public Keys direkt und ueber Gruppen. */
export async function verwendungenAufHost(hostId: string) {
const [oeffentlich, privat] = await Promise.all([
prisma.publicVerwendung.findMany({
where: {
zustand: { not: "ENTFERNT" },
OR: [{ hostId }, { hostgruppe: { mitglieder: { some: { hostId } } } }],
},
select: {
id: true,
zielbenutzer: true,
optionen: true,
zustand: true,
hostgruppe: { select: { name: true } },
schluessel: { select: { id: true, name: true, typ: true, fingerprint: true } },
},
orderBy: [{ zielbenutzer: "asc" }, { angelegtAm: "asc" }],
}),
prisma.privateVerwendung.findMany({
where: { hostId, zustand: { not: "ENTFERNT" } },
select: {
id: true,
zielbenutzer: true,
pfad: true,
zustand: true,
schluessel: { select: { id: true, name: true, typ: true, fingerprint: true } },
},
orderBy: [{ zielbenutzer: "asc" }, { pfad: "asc" }],
}),
]);
return { oeffentlich, privat };
}
+12 -1
View File
@@ -100,11 +100,20 @@ export type Ziel = { readonly hostId: string; readonly zielbenutzer: string };
export async function zieleVonVerwendungen(where: { export async function zieleVonVerwendungen(where: {
schluesselId?: string; schluesselId?: string;
ids?: string[]; ids?: string[];
hostId?: string;
}): Promise<Ziel[]> { }): Promise<Ziel[]> {
const verwendungen = await prisma.publicVerwendung.findMany({ const verwendungen = await prisma.publicVerwendung.findMany({
where: { where: {
...(where.schluesselId ? { schluesselId: where.schluesselId } : {}), ...(where.schluesselId ? { schluesselId: where.schluesselId } : {}),
...(where.ids ? { id: { in: where.ids } } : {}), ...(where.ids ? { id: { in: where.ids } } : {}),
...(where.hostId
? {
OR: [
{ hostId: where.hostId },
{ hostgruppe: { mitglieder: { some: { hostId: where.hostId } } } },
],
}
: {}),
zustand: { not: "ENTFERNT" }, zustand: { not: "ENTFERNT" },
}, },
select: { select: {
@@ -117,9 +126,11 @@ export async function zieleVonVerwendungen(where: {
}); });
const ziele = new Map<string, Ziel>(); const ziele = new Map<string, Ziel>();
for (const verwendung of verwendungen) { for (const verwendung of verwendungen) {
const hostIds = verwendung.hostId const alleHostIds = verwendung.hostId
? [verwendung.hostId] ? [verwendung.hostId]
: (verwendung.hostgruppe?.mitglieder.filter((m) => !m.host.archiviertAm).map((m) => m.hostId) ?? []); : (verwendung.hostgruppe?.mitglieder.filter((m) => !m.host.archiviertAm).map((m) => m.hostId) ?? []);
// Bei der Auswahl nach Host nur diesen Host, auch wenn die Verwendung an einer Gruppe haengt.
const hostIds = where.hostId ? alleHostIds.filter((id) => id === where.hostId) : alleHostIds;
for (const hostId of hostIds) { for (const hostId of hostIds) {
ziele.set(`${hostId}\u0000${verwendung.zielbenutzer}`, { ziele.set(`${hostId}\u0000${verwendung.zielbenutzer}`, {
hostId, hostId,
+4 -1
View File
@@ -117,13 +117,14 @@ export async function publicVerwendungEntfernen(id: string, akteur: Akteur): Pro
/** Trockenlauf fuer die Ziele eines Schluessels oder fuer ausgewaehlte Verwendungen. */ /** Trockenlauf fuer die Ziele eines Schluessels oder fuer ausgewaehlte Verwendungen. */
export async function trockenlaufStarten( export async function trockenlaufStarten(
auswahl: { schluesselId?: string; verwendungIds?: string[]; ziele?: Ziel[] }, auswahl: { schluesselId?: string; hostId?: string; verwendungIds?: string[]; ziele?: Ziel[] },
akteur: Akteur, akteur: Akteur,
): Promise<string> { ): Promise<string> {
const ziele = const ziele =
auswahl.ziele ?? auswahl.ziele ??
(await zieleVonVerwendungen({ (await zieleVonVerwendungen({
...(auswahl.schluesselId ? { schluesselId: auswahl.schluesselId } : {}), ...(auswahl.schluesselId ? { schluesselId: auswahl.schluesselId } : {}),
...(auswahl.hostId ? { hostId: auswahl.hostId } : {}),
...(auswahl.verwendungIds ? { ids: auswahl.verwendungIds } : {}), ...(auswahl.verwendungIds ? { ids: auswahl.verwendungIds } : {}),
})); }));
if (ziele.length === 0) throw new VerwendungsFehler("Es gibt nichts auszurollen."); if (ziele.length === 0) throw new VerwendungsFehler("Es gibt nichts auszurollen.");
@@ -131,6 +132,7 @@ export async function trockenlaufStarten(
art: "TROCKENLAUF", art: "TROCKENLAUF",
angefordertVonId: akteur.id, angefordertVonId: akteur.id,
schluesselId: auswahl.schluesselId ?? null, schluesselId: auswahl.schluesselId ?? null,
hostId: auswahl.hostId ?? null,
eingabe: { ziele }, eingabe: { ziele },
}); });
} }
@@ -144,6 +146,7 @@ export async function ausrollenStarten(trockenlaufId: string, akteur: Akteur): P
art: "AUSROLLEN", art: "AUSROLLEN",
angefordertVonId: akteur.id, angefordertVonId: akteur.id,
schluesselId: trockenlauf.schluesselId, schluesselId: trockenlauf.schluesselId,
hostId: trockenlauf.hostId,
eingabe: { trockenlaufId }, eingabe: { trockenlaufId },
}); });
} }