feat(ssh): strikte Host-Key-Pruefung und TOFU im Worker (#009)
Jede Verbindung prueft den Host-Key gegen die vertrauten Keys des Hosts und bietet nur deren Algorithmen an - sonst koennte der Server einen Key eines anderen Typs zeigen, den nie jemand gesehen hat. Ein abweichender Key bricht vor dem Senden von Zugangsdaten ab, wird als ausstehend festgehalten und oeffnet eine Abweichung. Abrufen bricht die Verbindung im hostVerifier ab: der Mensch entscheidet, nie der Code. Einen vertrauten Key zu ersetzen verlangt ein ausdrueckliches ersetzen und eine frische Bestaetigung. Integrationstest gegen ein echtes sshd inklusive Host-Key-Wechsel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState, useTransition, type ReactNode } from "react";
|
||||
import { Button, type ButtonVariante } from "@/components/ui/Button";
|
||||
|
||||
/**
|
||||
* Startet einen Vorgang im Worker und zeigt seinen Fortschritt (#009). Fragt den Status
|
||||
* jede Sekunde ab und laedt nach dem Ende die Seite neu, damit Server Components die neuen
|
||||
* Daten zeigen.
|
||||
*/
|
||||
type Antwort = { readonly vorgangId?: string; readonly fehler?: string };
|
||||
type Status = { status: string; fehler: string | null };
|
||||
|
||||
const LAUFEND = new Set(["WARTEND", "LAEUFT"]);
|
||||
|
||||
export function VorgangsKnopf({
|
||||
aktion,
|
||||
beschriftung,
|
||||
variante = "sekundaer",
|
||||
icon,
|
||||
klein,
|
||||
}: {
|
||||
aktion: () => Promise<Antwort>;
|
||||
beschriftung: string;
|
||||
variante?: ButtonVariante;
|
||||
icon?: ReactNode;
|
||||
klein?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [vorgangId, setVorgangId] = useState<string>();
|
||||
const [status, setStatus] = useState<Status>();
|
||||
const [fehler, setFehler] = useState<string>();
|
||||
const [startet, starten] = useTransition();
|
||||
const abbrechen = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!vorgangId) return;
|
||||
abbrechen.current = false;
|
||||
const abfragen = async () => {
|
||||
while (!abbrechen.current) {
|
||||
const antwort = await fetch(`/api/vorgaenge/${vorgangId}`, { cache: "no-store" });
|
||||
if (!antwort.ok) {
|
||||
setFehler("Status des Vorgangs nicht abrufbar.");
|
||||
return;
|
||||
}
|
||||
const neu = (await antwort.json()) as Status;
|
||||
setStatus(neu);
|
||||
if (!LAUFEND.has(neu.status)) {
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
await new Promise((weiter) => setTimeout(weiter, 1000));
|
||||
}
|
||||
};
|
||||
void abfragen();
|
||||
return () => {
|
||||
abbrechen.current = true;
|
||||
};
|
||||
}, [vorgangId, router]);
|
||||
|
||||
const laeuft =
|
||||
startet || (status !== undefined && LAUFEND.has(status.status)) || (vorgangId !== undefined && !status);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Button
|
||||
variante={variante}
|
||||
icon={icon}
|
||||
klein={klein}
|
||||
disabled={laeuft}
|
||||
onClick={() =>
|
||||
starten(async () => {
|
||||
setFehler(undefined);
|
||||
setStatus(undefined);
|
||||
const antwort = await aktion();
|
||||
if (antwort.fehler) setFehler(antwort.fehler);
|
||||
else setVorgangId(antwort.vorgangId);
|
||||
})
|
||||
}
|
||||
>
|
||||
{laeuft ? "Läuft …" : beschriftung}
|
||||
</Button>
|
||||
<p role="status" className="text-[13px]">
|
||||
{fehler && <span className="text-danger">{fehler}</span>}
|
||||
{!fehler && status?.status === "FEHLGESCHLAGEN" && (
|
||||
<span className="text-danger">{status.fehler}</span>
|
||||
)}
|
||||
{!fehler && status?.status === "ERLEDIGT" && <span className="text-ok">Erledigt.</span>}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user