Admins koennen einen gespeicherten Private Key nach frischer Bestaetigung einmalig anzeigen oder als Datei <slug> herunterladen und ihn endgueltig aus SSHKeyVault loeschen. Warum so: - Der Web-Prozess hat keinen Master-Key. Er bittet den Worker per Vorgang, den Key zu oeffnen; der legt ihn mit SSHKV_APP_SECRET verschluesselt in Vorgang.ergebnis ab. Das Web holt ihn genau einmal ab: ein einziges UPDATE unter Zeilensperre liest und ueberschreibt. - Nicht abgeholte Keys verfallen nach zwei Minuten: Worker-Takt alle 30 s, Aufraeumen vor jeder Abholung, und das Web gibt aeltere nicht mehr heraus. Die Status-Schnittstelle zeigt nur "bereit". - Download nur per POST (ein GET-Link oder Vorabruf wuerde die Abholung verbrauchen), no-store, Herkunft per Sec-Fetch-Site, weil no-referrer bei Formular-POSTs Origin: null sendet. - Loeschen laeuft als Worker-Vorgang, damit nur geheimnisse.ts die Chiffrat-Felder anfasst; eigenes Recht privateKeyLoeschen mit Bestaetigung. Deployment-Keys gibt sshkv weder heraus noch loescht es sie. - Protokoll: angefordert, angezeigt, heruntergeladen, geloescht - mit Fingerprint, nie mit Key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
313 lines
12 KiB
TypeScript
313 lines
12 KiB
TypeScript
import { Download, Lock, LockOpen, Pencil } from "lucide-react";
|
||
import type { Metadata } from "next";
|
||
import Link from "next/link";
|
||
import { notFound } from "next/navigation";
|
||
import type { ReactNode } from "react";
|
||
import { AblaufBadge, KategorieBadge, StatusBadge, TypBadge } from "@/components/schluessel/Anzeige";
|
||
import { Badge } from "@/components/ui/Badge";
|
||
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||
import { Button } from "@/components/ui/Button";
|
||
import { InnereBox, Karte } from "@/components/ui/Karte";
|
||
import { MonoBlock } from "@/components/ui/MonoBlock";
|
||
import { MonoKopieren } from "@/components/ui/MonoKopieren";
|
||
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||
import { Statuspunkt, type HostStatus } from "@/components/ui/Statuspunkt";
|
||
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 { PrivateAusrollenAktion } from "./PrivateAusrollenAktion";
|
||
import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen";
|
||
import { PublicAusrollenAktion } from "./PublicAusrollenAktion";
|
||
|
||
export const metadata: Metadata = { title: "Schlüssel" };
|
||
|
||
function InfoFeld({ titel, children }: { titel: string; children: ReactNode }) {
|
||
return (
|
||
<div className="min-w-0 px-5 py-4 sm:px-6">
|
||
<dt className="text-muted mb-1.5 text-[13px] font-medium">{titel}</dt>
|
||
<dd className="min-w-0">{children}</dd>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Soll-Zustand einer Verwendung als Statuspunkt – Form und Text tragen die Information mit. */
|
||
const ZUSTAND: Record<string, { status: HostStatus; text: string }> = {
|
||
GEPLANT: { status: "unbekannt", text: "geplant" },
|
||
AUSGEROLLT: { status: "synchron", text: "ausgerollt" },
|
||
ABWEICHUNG: { status: "drift", text: "Abweichung" },
|
||
ENTFERNEN: { status: "unbekannt", text: "wird entfernt" },
|
||
FEHLGESCHLAGEN: { status: "drift", text: "fehlgeschlagen" },
|
||
};
|
||
|
||
type Eintrag = { id: string; ziel: string; href?: string; pfad: string; zusatz?: string; zustand: string };
|
||
|
||
function AusrollKarte({
|
||
titel,
|
||
untertitel,
|
||
eintraege,
|
||
leer,
|
||
aktion,
|
||
}: {
|
||
titel: string;
|
||
untertitel: string;
|
||
eintraege: Eintrag[];
|
||
leer: string;
|
||
aktion: ReactNode;
|
||
}) {
|
||
return (
|
||
<Karte titel={titel} untertitel={untertitel} aktionen={aktion}>
|
||
{eintraege.length === 0 ? (
|
||
<p className="text-muted border-border-strong rounded-box border border-dashed px-4 py-6 text-center text-[14px]">
|
||
{leer}
|
||
</p>
|
||
) : (
|
||
<ul className="flex flex-col gap-3">
|
||
{eintraege.map((e) => {
|
||
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>}
|
||
</div>
|
||
<Statuspunkt status={z.status} text={z.text} className="shrink-0 sm:pt-0.5" />
|
||
</InnereBox>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
)}
|
||
</Karte>
|
||
);
|
||
}
|
||
|
||
function privateEintraege(s: SchluesselDetail): Eintrag[] {
|
||
return s.privateVerwendungen.map((v) => {
|
||
const config =
|
||
v.sshConfig && typeof v.sshConfig === "object" ? (v.sshConfig as { alias?: unknown }) : null;
|
||
return {
|
||
id: v.id,
|
||
ziel: `${v.zielbenutzer}@${v.host.name}`,
|
||
href: `/hosts/${v.host.id}`,
|
||
pfad: v.pfad,
|
||
...(typeof config?.alias === "string" ? { zusatz: `config: Host ${config.alias}` } : {}),
|
||
zustand: v.zustand,
|
||
};
|
||
});
|
||
}
|
||
|
||
function publicEintraege(s: SchluesselDetail): Eintrag[] {
|
||
return s.publicVerwendungen.map((v) => ({
|
||
id: v.id,
|
||
ziel: v.host
|
||
? `${v.zielbenutzer}@${v.host.name}`
|
||
: `${v.zielbenutzer}@${v.hostgruppe?.name ?? "?"} (Gruppe)`,
|
||
...(v.host
|
||
? { href: `/hosts/${v.host.id}` }
|
||
: v.hostgruppe
|
||
? { href: `/hostgruppen/${v.hostgruppe.id}` }
|
||
: {}),
|
||
pfad: "~/.ssh/authorized_keys",
|
||
...(v.optionen ? { zusatz: v.optionen } : {}),
|
||
zustand: v.zustand,
|
||
}));
|
||
}
|
||
|
||
/** Was mit dem Private Key ist – ohne ihn je zu lesen (masterKeyVersion verraet es). */
|
||
function PrivatStatus({ s, anzeigen }: { s: SchluesselDetail; anzeigen?: ReactNode }) {
|
||
if (s.privatGespeichert) {
|
||
return (
|
||
<span className="inline-flex flex-wrap items-center gap-x-1.5 text-[14px]">
|
||
<Lock className="text-ok size-3.5" aria-hidden />
|
||
verschlüsselt gespeichert
|
||
{s.passphraseGeschuetzt && <span className="text-muted">· mit Passphrase</span>}
|
||
{anzeigen}
|
||
</span>
|
||
);
|
||
}
|
||
if (s.privatGeloeschtAm) {
|
||
return (
|
||
<span className="text-muted inline-flex items-center gap-1.5 text-[14px]">
|
||
<LockOpen className="size-3.5" aria-hidden />
|
||
gelöscht am <time dateTime={s.privatGeloeschtAm.toISOString()}>{datum(s.privatGeloeschtAm)}</time>
|
||
</span>
|
||
);
|
||
}
|
||
return (
|
||
<span className="text-muted text-[14px]">
|
||
nicht gespeichert{s.kategorie === "PERSON" ? " (Personen-Key)" : ""}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/** Schlüsseldetail (#012) nach dem Prototyp aus #002; Ausrollen folgt mit #016/#017. */
|
||
export default async function SchluesselSeite({ params }: { params: Promise<{ id: string }> }) {
|
||
const { id } = await params;
|
||
const { benutzer } = await sitzungErforderlich("lesen", `/schluessel/${id}`);
|
||
const s = await schluesselDetail(id);
|
||
if (!s) notFound();
|
||
|
||
const aktiv = s.status === "AKTIV";
|
||
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.
|
||
const privatHerausgebbar = s.privatGespeichert && s.kategorie !== "DEPLOYMENT";
|
||
const darfAnzeigen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyAnzeigen");
|
||
const darfLoeschen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyLoeschen");
|
||
const jetzt = new Date();
|
||
|
||
return (
|
||
<div className="flex flex-col gap-6 sm:gap-7">
|
||
<Seitenkopf
|
||
oben={<Breadcrumb pfad={[{ titel: "Schlüssel", href: "/schluessel" }, { titel: s.name }]} />}
|
||
titel={s.name}
|
||
untertitel={
|
||
s.person ? (
|
||
<>
|
||
Person:{" "}
|
||
<Link href={`/personen/${s.person.id}`} className="text-accent hover:underline">
|
||
{s.person.name}
|
||
</Link>
|
||
</>
|
||
) : undefined
|
||
}
|
||
badges={
|
||
<>
|
||
<TypBadge typ={s.typ} publicKey={s.publicKey} />
|
||
<KategorieBadge kategorie={s.kategorie} />
|
||
<StatusBadge status={s.status} />
|
||
{s.instanzStandard && <Badge variante="messing">Instanz-Standard</Badge>}
|
||
{s.selbstHinterlegt && <Badge>selbst hinterlegt</Badge>}
|
||
{s.vorlage && <Badge>Vorlage: {s.vorlage.name}</Badge>}
|
||
<AblaufBadge ablaufAm={s.ablaufAm} jetzt={jetzt} lang />
|
||
{s.tags.map((t) => (
|
||
<Badge key={t} mono className="text-muted">
|
||
#{t}
|
||
</Badge>
|
||
))}
|
||
</>
|
||
}
|
||
aktionen={
|
||
verwalten && (
|
||
<Button href={`/schluessel/${s.id}/bearbeiten`} icon={<Pencil className="size-4" />}>
|
||
Bearbeiten
|
||
</Button>
|
||
)
|
||
}
|
||
/>
|
||
|
||
<Karte as="div" innen="ohne">
|
||
<dl className="divide-border grid divide-y md:grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,1.1fr)] md:divide-x md:divide-y-0">
|
||
<InfoFeld titel="Fingerprint">
|
||
<MonoKopieren wert={s.fingerprint} beschriftung="Fingerprint" umbrechen className="-my-[11px]" />
|
||
</InfoFeld>
|
||
<InfoFeld titel="Erstellt">
|
||
<time dateTime={s.angelegtAm.toISOString()} className="font-mono text-[13px]">
|
||
{datumZeit(s.angelegtAm)}
|
||
</time>
|
||
<span className="text-muted block text-[13px]">
|
||
{s.erstelltVon ? `von ${s.erstelltVon.name}` : "von sshkv"}
|
||
</span>
|
||
</InfoFeld>
|
||
<InfoFeld titel="Private Key">
|
||
<PrivatStatus
|
||
s={s}
|
||
anzeigen={
|
||
darfAnzeigen && (
|
||
<>
|
||
<span aria-hidden>·</span>
|
||
<PrivateKeyAnzeigen schluesselId={s.id} name={s.name} />
|
||
</>
|
||
)
|
||
}
|
||
/>
|
||
</InfoFeld>
|
||
</dl>
|
||
</Karte>
|
||
|
||
{/* Personen-Keys haben nie einen gespeicherten Private Key (DB-CHECK) – die Karte dafuer
|
||
waere immer leer. */}
|
||
<div className={personenKey ? "grid gap-6" : "grid gap-6 lg:grid-cols-2"}>
|
||
{!personenKey && (
|
||
<AusrollKarte
|
||
titel="Private Key ausgerollt auf"
|
||
untertitel="Hosts, die sich mit diesem Schlüssel anmelden"
|
||
eintraege={privateEintraege(s)}
|
||
leer={
|
||
s.privatGespeichert
|
||
? "Der Private Key liegt noch auf keinem Host."
|
||
: "Ohne gespeicherten Private Key kann sshkv ihn nicht ausrollen."
|
||
}
|
||
aktion={
|
||
<PrivateAusrollenAktion
|
||
schluesselId={s.id}
|
||
darf={aktiv && s.privatGespeichert && darf(benutzer.rolle, "privateKeyAusrollen")}
|
||
/>
|
||
}
|
||
/>
|
||
)}
|
||
<AusrollKarte
|
||
titel="Public Key ausgerollt auf"
|
||
untertitel="Konten, die diesen Schlüssel annehmen"
|
||
eintraege={publicEintraege(s)}
|
||
leer={
|
||
personenKey
|
||
? "Noch keinem Konto zugewiesen – wirksam erst, wenn ein Operator oder Admin den Schlüssel einem Host zuweist."
|
||
: "Noch keinem Konto zugewiesen."
|
||
}
|
||
aktion={
|
||
<PublicAusrollenAktion
|
||
schluesselId={s.id}
|
||
darf={aktiv && darf(benutzer.rolle, "publicKeyAusrollen")}
|
||
/>
|
||
}
|
||
/>
|
||
</div>
|
||
|
||
<Karte
|
||
titel="Public Key"
|
||
untertitel={
|
||
<>
|
||
Kommentar: <span className="font-mono">{s.kommentar || "–"}</span>
|
||
</>
|
||
}
|
||
aktionen={
|
||
<a
|
||
href={`/api/schluessel/${s.id}/public-key`}
|
||
download={`${s.slug}.pub`}
|
||
className="border-border-strong bg-surface text-text hover:bg-raised rounded-box inline-flex min-h-11 items-center gap-2 border px-3 text-[13px] font-medium transition-colors"
|
||
>
|
||
<Download className="size-4" aria-hidden />
|
||
{s.slug}.pub
|
||
</a>
|
||
}
|
||
>
|
||
<MonoBlock wert={s.publicKey} beschriftung="Public Key" umbrechen />
|
||
</Karte>
|
||
|
||
{darfLoeschen && (
|
||
<Karte titel="Private Key löschen" untertitel="Nur noch auf den Hosts, nicht mehr in SSHKeyVault">
|
||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||
<p className="text-muted max-w-xl text-[14px]">
|
||
Sinnvoll, sobald der Private Key ausgerollt ist und sshkv ihn nicht mehr braucht. Danach kann
|
||
ihn niemand mehr aus SSHKeyVault holen – auch kein Admin.
|
||
</p>
|
||
<PrivateKeyLoeschen schluesselId={s.id} name={s.name} />
|
||
</div>
|
||
</Karte>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|