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:
@@ -18,6 +18,9 @@ import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { hostDetail, type HostDetail } from "@/server/queries/hosts";
|
||||
import { BootstrapAktionen } from "./BootstrapAktionen";
|
||||
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 { HostKeyEntscheidung } from "./HostKeyEntscheidung";
|
||||
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 verwalten = darf(benutzer.rolle, "hostsVerwalten") && !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();
|
||||
|
||||
return (
|
||||
@@ -217,7 +222,7 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri
|
||||
|
||||
<Karte
|
||||
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 && (
|
||||
<p className="text-muted mb-4 text-[14px]">
|
||||
@@ -230,11 +235,64 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
<Leerzustand
|
||||
icon={<KeyRound className="size-5" />}
|
||||
titel="Folgt mit #016"
|
||||
text="Hier erscheinen die Public und Private Keys, die sshkv auf diesem Host verwaltet."
|
||||
/>
|
||||
{verwendungen.oeffentlich.length === 0 && verwendungen.privat.length === 0 ? (
|
||||
<Leerzustand
|
||||
icon={<KeyRound className="size-5" />}
|
||||
titel="Noch keine Schlüssel zugewiesen"
|
||||
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>
|
||||
|
||||
{verwalten && (
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useEffect, useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { DiffBox } from "@/components/ui/DiffBox";
|
||||
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";
|
||||
|
||||
/**
|
||||
@@ -24,7 +24,7 @@ type PlanZiel = {
|
||||
fehler?: string;
|
||||
};
|
||||
|
||||
export function AusrollenPanel({ schluesselId, offen }: { schluesselId: string; offen: number }) {
|
||||
export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; offen: number }) {
|
||||
const router = useRouter();
|
||||
const [trockenlaufId, setTrockenlaufId] = useState<string>();
|
||||
const [ausrollenId, setAusrollenId] = useState<string>();
|
||||
@@ -52,7 +52,7 @@ export function AusrollenPanel({ schluesselId, offen }: { schluesselId: string;
|
||||
starten(async () => {
|
||||
setFehler(undefined);
|
||||
setAusrollenId(undefined);
|
||||
const antwort = await trockenlaufAktion(schluesselId);
|
||||
const antwort = await trockenlaufAktion(auswahl);
|
||||
if (antwort.fehler) setFehler(antwort.fehler);
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
const { benutzer } = await rechtErforderlich("publicKeyAusrollen");
|
||||
const id = z.string().min(1).max(64);
|
||||
return {
|
||||
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),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -20,3 +20,36 @@ export async function 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 };
|
||||
}
|
||||
|
||||
@@ -100,11 +100,20 @@ export type Ziel = { readonly hostId: string; readonly zielbenutzer: string };
|
||||
export async function zieleVonVerwendungen(where: {
|
||||
schluesselId?: string;
|
||||
ids?: string[];
|
||||
hostId?: string;
|
||||
}): Promise<Ziel[]> {
|
||||
const verwendungen = await prisma.publicVerwendung.findMany({
|
||||
where: {
|
||||
...(where.schluesselId ? { schluesselId: where.schluesselId } : {}),
|
||||
...(where.ids ? { id: { in: where.ids } } : {}),
|
||||
...(where.hostId
|
||||
? {
|
||||
OR: [
|
||||
{ hostId: where.hostId },
|
||||
{ hostgruppe: { mitglieder: { some: { hostId: where.hostId } } } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
zustand: { not: "ENTFERNT" },
|
||||
},
|
||||
select: {
|
||||
@@ -117,9 +126,11 @@ export async function zieleVonVerwendungen(where: {
|
||||
});
|
||||
const ziele = new Map<string, Ziel>();
|
||||
for (const verwendung of verwendungen) {
|
||||
const hostIds = verwendung.hostId
|
||||
const alleHostIds = verwendung.hostId
|
||||
? [verwendung.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) {
|
||||
ziele.set(`${hostId}\u0000${verwendung.zielbenutzer}`, {
|
||||
hostId,
|
||||
|
||||
@@ -117,13 +117,14 @@ export async function publicVerwendungEntfernen(id: string, akteur: Akteur): Pro
|
||||
|
||||
/** Trockenlauf fuer die Ziele eines Schluessels oder fuer ausgewaehlte Verwendungen. */
|
||||
export async function trockenlaufStarten(
|
||||
auswahl: { schluesselId?: string; verwendungIds?: string[]; ziele?: Ziel[] },
|
||||
auswahl: { schluesselId?: string; hostId?: string; verwendungIds?: string[]; ziele?: Ziel[] },
|
||||
akteur: Akteur,
|
||||
): Promise<string> {
|
||||
const ziele =
|
||||
auswahl.ziele ??
|
||||
(await zieleVonVerwendungen({
|
||||
...(auswahl.schluesselId ? { schluesselId: auswahl.schluesselId } : {}),
|
||||
...(auswahl.hostId ? { hostId: auswahl.hostId } : {}),
|
||||
...(auswahl.verwendungIds ? { ids: auswahl.verwendungIds } : {}),
|
||||
}));
|
||||
if (ziele.length === 0) throw new VerwendungsFehler("Es gibt nichts auszurollen.");
|
||||
@@ -131,6 +132,7 @@ export async function trockenlaufStarten(
|
||||
art: "TROCKENLAUF",
|
||||
angefordertVonId: akteur.id,
|
||||
schluesselId: auswahl.schluesselId ?? null,
|
||||
hostId: auswahl.hostId ?? null,
|
||||
eingabe: { ziele },
|
||||
});
|
||||
}
|
||||
@@ -144,6 +146,7 @@ export async function ausrollenStarten(trockenlaufId: string, akteur: Akteur): P
|
||||
art: "AUSROLLEN",
|
||||
angefordertVonId: akteur.id,
|
||||
schluesselId: trockenlauf.schluesselId,
|
||||
hostId: trockenlauf.hostId,
|
||||
eingabe: { trockenlaufId },
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user