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:
@@ -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={
|
||||
|
||||
Reference in New Issue
Block a user