feat(hosts): Host-Key und Bootstrap in der Oberflaeche (#009, #010)

Auf der Hostseite: Host-Key abrufen, Fingerprint vergleichen und
vertrauen oder ablehnen. Weicht ein Key vom vertrauten ab, erscheint
eine deutliche Warnung, und Annehmen geht nur mit Haekchen und frischer
Bestaetigung. Danach Bootstrap per Einmal-Passwort oder per Befehl und
Verbindungspruefung, jeweils mit Fortschrittsanzeige.

Der sshd-Befund empfiehlt prohibit-password, wenn root sich per
Passwort anmelden darf - nur als Hinweis, geaendert wird nichts.

E2E im gebauten Stand mit Worker gegen ein echtes sshd.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 10:00:43 +02:00
co-authored by Claude Opus 5.5
parent c87c7b4e43
commit 4584669962
12 changed files with 450 additions and 45 deletions
+64 -15
View File
@@ -1,19 +1,68 @@
export type BootstrapAktionenProps = {
readonly hostId: string;
/** Recht `hostsVerwalten` – serverseitig per `darf()` ermittelt. */
readonly darfVerwalten: boolean;
/** `bootstrapAm` ist gesetzt: sshkv arbeitet bereits schluesselbasiert. */
readonly eingerichtet: boolean;
/** Mindestens ein vertrauter Host-Key – Voraussetzung fuer jede Verbindung. */
readonly hostKeyVertraut: boolean;
};
import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
import { MonoBlock } from "@/components/ui/MonoBlock";
import { bootstrapPruefenAktion } from "@/server/actions/bootstrap";
import { erreichbarkeitPruefenAktion } from "@/server/actions/hostkeys";
import { BootstrapPasswort } from "./BootstrapPasswort";
/**
* Platzhalter fuer „Zugang einrichten“ (Bootstrap, #010). Die Detailseite rendert diese
* Komponente unter der Info-Leiste; #010 ersetzt nur diese Datei.
* Bootstrap und Verbindungspruefung im Info-Bereich des Hosts (#010). Zwei Wege zum ersten
* Zugang: einmaliges Passwort (wird nie gespeichert) oder Befehl zum Kopieren.
*/
export function BootstrapAktionen(props: BootstrapAktionenProps) {
// Die Props stehen schon fest, damit die Detailseite bis #009/#010 unveraendert bleibt.
void props;
return null;
export function BootstrapAktionen({
hostId,
darfVerwalten,
eingerichtet,
hostKeyVertraut,
befehl,
}: {
hostId: string;
darfVerwalten: boolean;
eingerichtet: boolean;
hostKeyVertraut: boolean;
/** Bootstrap-Befehl mit dem Public Key des Deployment-Keys. */
befehl?: string;
}) {
if (!darfVerwalten) return null;
if (!hostKeyVertraut) {
return (
<p className="border-border text-muted mt-5 border-t pt-4 text-[14px]">
Bevor sshkv sich anmeldet, muss der Host-Key bestätigt sein – siehe unten.
</p>
);
}
if (eingerichtet) {
return (
<div className="border-border mt-5 flex flex-wrap gap-3 border-t pt-4">
<VorgangsKnopf
aktion={erreichbarkeitPruefenAktion.bind(null, hostId)}
beschriftung="Verbindung prüfen"
/>
<VorgangsKnopf
aktion={bootstrapPruefenAktion.bind(null, hostId)}
beschriftung="Einrichtung erneut prüfen"
/>
</div>
);
}
return (
<div className="border-border mt-5 grid gap-6 border-t pt-5 lg:grid-cols-2">
<section className="flex flex-col gap-3">
<h3 className="font-display text-text text-[16px] font-semibold">Einmalig per Passwort</h3>
<p className="text-muted text-[14px]">
sshkv meldet sich ein einziges Mal mit Passwort an, legt den Zugang an und arbeitet danach nur noch
mit seinem Deployment-Key. Das Passwort wird{" "}
<strong className="text-text">nicht gespeichert</strong>.
</p>
<BootstrapPasswort hostId={hostId} />
</section>
<section className="flex min-w-0 flex-col gap-3">
<h3 className="font-display text-text text-[16px] font-semibold">Oder per Befehl</h3>
<p className="text-muted text-[14px]">
Auf dem Host als Benutzer mit sudo einfügen, danach prüfen lassen.
</p>
{befehl && <MonoBlock wert={befehl} beschriftung="Bootstrap-Befehl" hoehe="16rem" />}
<VorgangsKnopf aktion={bootstrapPruefenAktion.bind(null, hostId)} beschriftung="Einrichtung prüfen" />
</section>
</div>
);
}
@@ -0,0 +1,48 @@
"use client";
import { KeyRound } from "lucide-react";
import { useState } from "react";
import { Feld } from "@/components/ui/Feld";
import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
import { bootstrapPerPasswortAktion } from "@/server/actions/bootstrap";
/** Formular fuer den Bootstrap per Einmal-Passwort (#010). */
export function BootstrapPasswort({ hostId }: { hostId: string }) {
const [benutzer, setBenutzer] = useState("root");
const [passwort, setPasswort] = useState("");
return (
<div className="flex flex-col gap-3">
<div className="grid gap-3 sm:grid-cols-2">
<Feld
beschriftung="Benutzer"
name="benutzer"
value={benutzer}
onChange={(e) => setBenutzer(e.target.value)}
autoComplete="off"
mono
hinweis="root oder ein Benutzer mit sudo"
/>
<Feld
beschriftung="Passwort"
name="passwort"
type="password"
value={passwort}
onChange={(e) => setPasswort(e.target.value)}
autoComplete="off"
/>
</div>
<VorgangsKnopf
variante="primaer"
icon={<KeyRound className="size-4" />}
beschriftung="Zugang einrichten"
aktion={async () => {
const antwort = await bootstrapPerPasswortAktion({ hostId, benutzer, passwort });
// Das Passwort bleibt keinen Augenblick laenger als noetig im Formular stehen.
setPasswort("");
return antwort;
}}
/>
</div>
);
}
+14 -8
View File
@@ -1,4 +1,7 @@
import { Download } from "lucide-react";
import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
import type { HostKeyZusammenfassung } from "@/lib/hosts";
import { hostKeyAbrufenAktion } from "@/server/actions/hostkeys";
export type HostKeyAktionenProps = {
readonly hostId: string;
@@ -8,12 +11,15 @@ export type HostKeyAktionenProps = {
readonly status: HostKeyZusammenfassung;
};
/**
* Platzhalter fuer „Host-Key abrufen“ / „Bestätigen“ / „Ablehnen“ (#009). Die Detailseite
* rendert diese Komponente im Kopf der Karte „Host-Key“; #009 ersetzt nur diese Datei.
*/
export function HostKeyAktionen(props: HostKeyAktionenProps) {
// Die Props stehen schon fest, damit die Detailseite bis #009/#010 unveraendert bleibt.
void props;
return null;
/** „Host-Key abrufen“ im Kopf der Karte (#009). Entschieden wird je Zeile (HostKeyEntscheidung). */
export function HostKeyAktionen({ hostId, darfEntscheiden, status }: HostKeyAktionenProps) {
if (!darfEntscheiden) return null;
return (
<VorgangsKnopf
aktion={hostKeyAbrufenAktion.bind(null, hostId)}
beschriftung={status.zustand === "keiner" ? "Host-Key abrufen" : "Erneut abrufen"}
variante={status.zustand === "keiner" ? "primaer" : "sekundaer"}
icon={<Download className="size-4" />}
/>
);
}
@@ -0,0 +1,92 @@
"use client";
import { ShieldAlert } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/Button";
import { hostKeyEntscheidenAktion } from "@/server/actions/hostkeys";
/**
* Entscheidung ueber einen ausstehenden Host-Key (#009, TOFU). Weicht er von einem
* vertrauten Key ab, verlangt die Oberflaeche eine ausdrueckliche Bestaetigung – und der
* Server zusaetzlich eine frische Anmeldung.
*/
export function HostKeyEntscheidung({
hostId,
hostSchluesselId,
ersetztVertrauten,
}: {
hostId: string;
hostSchluesselId: string;
ersetztVertrauten: boolean;
}) {
const router = useRouter();
const [geprueft, setGeprueft] = useState(false);
const [fehler, setFehler] = useState<string>();
const [laeuft, starten] = useTransition();
const entscheiden = (vertrauen: boolean) =>
starten(async () => {
setFehler(undefined);
const antwort = await hostKeyEntscheidenAktion({
hostId,
hostSchluesselId,
vertrauen,
ersetzen: vertrauen && ersetztVertrauten,
});
if (antwort.bestaetigen) window.location.href = antwort.bestaetigen;
else if (antwort.fehler) setFehler(antwort.fehler);
else router.refresh();
});
return (
<div className="mt-3 flex flex-col gap-3 md:col-span-3">
{ersetztVertrauten ? (
<div className="border-danger-border bg-danger-soft rounded-box flex gap-3 border p-3 text-[14px]">
<ShieldAlert className="text-danger mt-0.5 size-5 shrink-0" aria-hidden />
<div className="flex flex-col gap-2">
<p className="text-danger font-medium">Dieser Key weicht vom vertrauten Host-Key ab.</p>
<p className="text-text">
Nur annehmen, wenn sicher ist, dass der Host neu installiert oder sein Host-Key bewusst
getauscht wurde. Sonst kann jemand die Verbindung abfangen.
</p>
<label className="flex min-h-11 items-center gap-3">
<input
type="checkbox"
className="accent-accent size-5"
checked={geprueft}
onChange={(e) => setGeprueft(e.target.checked)}
/>
<span>Ich habe den Fingerprint direkt am Host geprüft.</span>
</label>
</div>
</div>
) : (
<p className="text-muted text-[13.5px]">
Vergleichen Sie den Fingerprint am Host, z. B. mit{" "}
<code className="text-text font-mono text-[12.5px]">
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
</code>
.
</p>
)}
<div className="flex flex-wrap gap-2">
<Button
variante={ersetztVertrauten ? "gefahr" : "primaer"}
disabled={laeuft || (ersetztVertrauten && !geprueft)}
onClick={() => entscheiden(true)}
>
{ersetztVertrauten ? "Neuen Host-Key annehmen" : "Fingerprint stimmt – vertrauen"}
</Button>
<Button variante="sekundaer" disabled={laeuft} onClick={() => entscheiden(false)}>
Ablehnen
</Button>
</div>
{fehler && (
<p role="status" className="text-danger text-[13px]">
{fehler}
</p>
)}
</div>
);
}
+30 -5
View File
@@ -17,7 +17,10 @@ import { darf } from "@/server/auth/rechte";
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 { HostKeyAktionen } from "./HostKeyAktionen";
import { HostKeyEntscheidung } from "./HostKeyEntscheidung";
import { bootstrapBefehlFuer } from "@/server/queries/bootstrap";
export const metadata: Metadata = { title: "Host" };
@@ -52,6 +55,7 @@ function SshdBefund({ befund }: { befund: unknown }) {
if (!befund || typeof befund !== "object" || Array.isArray(befund)) return null;
const eintraege = Object.entries(befund as Record<string, unknown>);
if (eintraege.length === 0) return null;
const hinweise = sshdHinweise(befund as Record<string, unknown>);
return (
<div className="border-border mt-5 border-t pt-4">
<p className="text-muted text-[13px] font-medium">sshd-Befund</p>
@@ -63,6 +67,20 @@ function SshdBefund({ befund }: { befund: unknown }) {
</div>
))}
</dl>
{hinweise.length > 0 && (
<ul className="mt-3 flex flex-col gap-1.5 text-[13.5px]">
{hinweise.map((h) => (
<li
key={h.text}
className={
h.stufe === "gefahr" ? "text-danger" : h.stufe === "warnung" ? "text-accent" : "text-muted"
}
>
{h.stufe === "info" ? "ℹ" : "▲"} {h.text}
</li>
))}
</ul>
)}
</div>
);
}
@@ -76,6 +94,7 @@ 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 jetzt = new Date();
return (
@@ -142,6 +161,7 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri
darfVerwalten={verwalten}
eingerichtet={host.bootstrapAm !== null}
hostKeyVertraut={host.hostKey.vertraut > 0}
befehl={verwalten && !host.bootstrapAm ? await bootstrapBefehlFuer(host.id) : undefined}
/>
</Karte>
@@ -149,11 +169,7 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri
titel="Host-Key"
untertitel="Fingerprints, gegen die sshkv jede Verbindung prüft"
aktionen={
<HostKeyAktionen
hostId={host.id}
darfEntscheiden={darf(benutzer.rolle, "hostKeyEntscheiden") && !archiviert}
status={host.hostKey}
/>
<HostKeyAktionen hostId={host.id} darfEntscheiden={darfEntscheiden} status={host.hostKey} />
}
>
{host.hostSchluessel.length === 0 ? (
@@ -184,6 +200,15 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri
<span className="hidden md:block">
<HostSchluesselStatusBadge status={k.status} />
</span>
{k.status === "AUSSTEHEND" && darfEntscheiden && (
<HostKeyEntscheidung
hostId={host.id}
hostSchluesselId={k.id}
ersetztVertrauten={host.hostSchluessel.some(
(v) => v.status === "VERTRAUT" && v.algorithmus === k.algorithmus,
)}
/>
)}
</li>
))}
</ul>