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 { 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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user