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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
/** Mehrzeiliger technischer Text (Befehle, Skripte) mit Kopier-Button (#010). */
|
||||
export function MonoBlock({
|
||||
wert,
|
||||
beschriftung,
|
||||
hoehe,
|
||||
}: {
|
||||
wert: string;
|
||||
beschriftung: string;
|
||||
hoehe?: string;
|
||||
}) {
|
||||
const [kopiert, setKopiert] = useState(false);
|
||||
return (
|
||||
<div className="border-border bg-bg rounded-box relative min-w-0 border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(wert);
|
||||
setKopiert(true);
|
||||
setTimeout(() => setKopiert(false), 1800);
|
||||
} catch {
|
||||
// Ohne https keine Clipboard-API – der Text bleibt markierbar.
|
||||
}
|
||||
}}
|
||||
aria-label={`${beschriftung} kopieren`}
|
||||
className="bg-surface border-border-strong text-muted hover:text-text rounded-feld absolute top-2 right-2 flex size-11 items-center justify-center border"
|
||||
>
|
||||
{kopiert ? <Check className="text-ok size-4" aria-hidden /> : <Copy className="size-4" aria-hidden />}
|
||||
</button>
|
||||
<pre
|
||||
className="text-text overflow-auto p-4 pr-16 font-mono text-[12.5px] leading-[1.55]"
|
||||
style={hoehe ? { maxHeight: hoehe } : undefined}
|
||||
tabIndex={0}
|
||||
aria-label={beschriftung}
|
||||
>
|
||||
{wert}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Hinweise zum sshd-Befund eines Hosts (#010). Nur Anzeige: sshkv aendert sshd_config
|
||||
* nie ohne ausdrueckliche Bestaetigung (Vorgabe „Bootstrap“).
|
||||
*/
|
||||
export type SshdHinweis = { readonly stufe: "warnung" | "gefahr" | "info"; readonly text: string };
|
||||
|
||||
export function sshdHinweise(befund: Record<string, unknown> | null | undefined): SshdHinweis[] {
|
||||
if (!befund) return [];
|
||||
const wert = (name: string) =>
|
||||
typeof befund[name] === "string" ? (befund[name] as string).toLowerCase() : "";
|
||||
const hinweise: SshdHinweis[] = [];
|
||||
|
||||
if (wert("permitRootLogin") === "yes") {
|
||||
hinweise.push({
|
||||
stufe: "warnung",
|
||||
text: "root darf sich per Passwort anmelden. Empfohlen: PermitRootLogin prohibit-password.",
|
||||
});
|
||||
}
|
||||
if (wert("passwordAuthentication") === "yes") {
|
||||
hinweise.push({ stufe: "info", text: "Passwort-Anmeldung ist für alle Benutzer aktiv." });
|
||||
}
|
||||
if (wert("pubkeyAuthentication") === "no") {
|
||||
hinweise.push({
|
||||
stufe: "gefahr",
|
||||
text: "Anmeldung per Schlüssel ist abgeschaltet – sshkv kann so nicht arbeiten.",
|
||||
});
|
||||
}
|
||||
const dateien = wert("authorizedKeysFile");
|
||||
if (
|
||||
dateien &&
|
||||
!dateien.split(/\s+/).some((d) => d === ".ssh/authorized_keys" || d === "%h/.ssh/authorized_keys")
|
||||
) {
|
||||
hinweise.push({
|
||||
stufe: "gefahr",
|
||||
text: `sshd liest Schlüssel aus „${befund["authorizedKeysFile"] as string}“, sshkv schreibt nach ~/.ssh/authorized_keys.`,
|
||||
});
|
||||
}
|
||||
return hinweise;
|
||||
}
|
||||
Reference in New Issue
Block a user