diff --git a/docs/anleitung/bilder/host-eingerichtet-desktop.png b/docs/anleitung/bilder/host-eingerichtet-desktop.png
new file mode 100644
index 0000000..bf6d99a
Binary files /dev/null and b/docs/anleitung/bilder/host-eingerichtet-desktop.png differ
diff --git a/docs/issues/009-host-key-pruefung.md b/docs/issues/009-host-key-pruefung.md
index db19b93..2feb87b 100644
--- a/docs/issues/009-host-key-pruefung.md
+++ b/docs/issues/009-host-key-pruefung.md
@@ -1,23 +1,27 @@
# #009 Host-Key-Prüfung (TOFU) und Erreichbarkeit
-**Status:** offen
+**Status:** erledigt
**Meilenstein:** v0.1.0
**Labels:** sicherheit
-**Geschätzt:** 3 h **Tatsächlich:** –
+**Geschätzt:** 3 h **Tatsächlich:** 0 h 25 min
## Ziel
Beim ersten Kontakt wird der Host-Key angezeigt und bestätigt; danach strikte Prüfung, Änderung = Warnung.
## Aufgaben
-- [ ] Vorgang „Host-Key abrufen“ (ohne Anmeldung)
-- [ ] Bestätigen/Ablehnen mit Protokolleintrag
-- [ ] `hostVerifier` in ssh2 gegen gespeicherten Key
-- [ ] Geänderter Host-Key: Status GEAENDERT, keine Verbindung, deutliche Warnung
-- [ ] Erreichbarkeitsprüfung
+- [x] Vorgang „Host-Key abrufen“ (ohne Anmeldung)
+- [x] Bestätigen/Ablehnen mit Protokolleintrag
+- [x] `hostVerifier` in ssh2 gegen gespeicherten Key
+- [x] Geänderter Host-Key: Status GEAENDERT, keine Verbindung, deutliche Warnung
+- [x] Erreichbarkeitsprüfung
## Akzeptanzkriterien
-- [ ] Integrationstest: geänderter Host-Key wird abgelehnt
+- [x] Integrationstest: geänderter Host-Key wird abgelehnt
## Abhängigkeiten
#006, #007
## Notizen / Entscheidungen
+- **Nur vertraute Algorithmen anbieten:** `serverHostKey` enthält nur die Verfahren der vertrauten Keys (für `ssh-rsa` die `rsa-sha2-*`). Sonst könnte ein Server einen Key eines anderen Typs zeigen, den nie jemand gesehen hat.
+- **Abrufen bricht im `hostVerifier` ab** – vor jedem Austausch von Zugangsdaten.
+- **Geänderter Key:** wird als AUSSTEHEND festgehalten, öffnet eine Abweichung `HOSTKEY_GEAENDERT` und setzt den Host auf Drift. Ersetzen nur mit ausdrücklichem Häkchen in der Oberfläche **und** frischer Bestätigung (Re-Auth).
+- Integrationstest gegen echtes sshd inkl. Host-Key-Tausch; E2E im Browser (abrufen → vertrauen).
diff --git a/docs/issues/010-bootstrap.md b/docs/issues/010-bootstrap.md
index 8542aa1..38f6932 100644
--- a/docs/issues/010-bootstrap.md
+++ b/docs/issues/010-bootstrap.md
@@ -1,24 +1,30 @@
# #010 Bootstrap neuer Hosts
-**Status:** offen
+**Status:** erledigt
**Meilenstein:** v0.1.0
**Labels:** backend, ui
-**Geschätzt:** 4 h **Tatsächlich:** –
+**Geschätzt:** 4 h **Tatsächlich:** 0 h 35 min
## Ziel
Deployment-Key auf neuen Host bringen: einmalige Passwortanmeldung (nie gespeichert) oder Copy-Paste-Befehl. Dienstbenutzer `sshkv` + sudoers.
## Aufgaben
-- [ ] Einmal-Passwort nur im Arbeitsspeicher des Workers, verschlüsselt im Job, danach gelöscht
-- [ ] Bootstrap-Skript: Benutzer `sshkv`, `/etc/sudoers.d/sshkv` (geprüft mit `visudo -c`), Deployment-Key in verwaltetem Block
-- [ ] Alternative: Befehl zum Kopieren
-- [ ] Prüfung nach Bootstrap: Anmeldung per Schlüssel, sudo ohne Passwort
-- [ ] sshd-Check: `PermitRootLogin`, `PasswordAuthentication` (nur Anzeige)
+- [x] Einmal-Passwort nur im Arbeitsspeicher des Workers, verschlüsselt im Job, danach gelöscht
+- [x] Bootstrap-Skript: Benutzer `sshkv`, `/etc/sudoers.d/sshkv` (geprüft mit `visudo -c`), Deployment-Key in verwaltetem Block
+- [x] Alternative: Befehl zum Kopieren
+- [x] Prüfung nach Bootstrap: Anmeldung per Schlüssel, sudo ohne Passwort
+- [x] sshd-Check: `PermitRootLogin`, `PasswordAuthentication` (nur Anzeige)
## Akzeptanzkriterien
-- [ ] Integrationstest gegen Test-Container: Bootstrap per Passwort und per Befehl
-- [ ] Passwort taucht nirgends in DB, Logs oder Protokoll auf
+- [x] Integrationstest gegen Test-Container: Bootstrap per Passwort und per Befehl
+- [x] Passwort taucht nirgends in DB, Logs oder Protokoll auf
## Abhängigkeiten
#009
## Notizen / Entscheidungen
+- **Ein Skript für beide Wege** (Passwort im Worker, Befehl zum Kopieren) – keine zwei Wahrheiten über Änderungen am Host.
+- **Passwort:** reist verschlüsselt (`SSHKV_APP_SECRET`) nur im Job, Job wird nach dem Lauf gelöscht; Test: Passwort steht in keiner Tabelle. JS-Strings lassen sich nicht überschreiben – das Passwort lebt bis zur Speicherbereinigung im Worker-Speicher.
+- **Nicht-root mit sudo-Passwort** wird unterstützt (`sudo -S`).
+- **Symlink-Schutz:** Dateien im Home des Zielbenutzers liest und schreibt das Skript als dieser Benutzer (`runuser`). Test mit Symlink auf `/etc/shadow`: Abbruch, `/etc/shadow` unverändert.
+- **CRLF:** Das Bootstrap-Skript entfernt `\r` aus authorized_keys. Die bytegenaue Erhaltung fremder Zeilen leistet erst der TS-Merge aus #015.
+- **sshd-Hinweise** (nur Anzeige): `PermitRootLogin yes` → Empfehlung `prohibit-password`; abweichendes `AuthorizedKeysFile` → Warnung.
diff --git a/src/app/(app)/hosts/[id]/BootstrapAktionen.tsx b/src/app/(app)/hosts/[id]/BootstrapAktionen.tsx
index 245f258..60b2ca3 100644
--- a/src/app/(app)/hosts/[id]/BootstrapAktionen.tsx
+++ b/src/app/(app)/hosts/[id]/BootstrapAktionen.tsx
@@ -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 (
+
+ Bevor sshkv sich anmeldet, muss der Host-Key bestätigt sein – siehe unten.
+
+ );
+ }
+ if (eingerichtet) {
+ return (
+
+
+
+
+ );
+ }
+ return (
+
+
+
Einmalig per Passwort
+
+ 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{" "}
+ nicht gespeichert.
+
+
+
+
+
Oder per Befehl
+
+ Auf dem Host als Benutzer mit sudo einfügen, danach prüfen lassen.
+
+ {befehl && }
+
+
+
+ );
}
diff --git a/src/app/(app)/hosts/[id]/BootstrapPasswort.tsx b/src/app/(app)/hosts/[id]/BootstrapPasswort.tsx
new file mode 100644
index 0000000..a26a3ec
--- /dev/null
+++ b/src/app/(app)/hosts/[id]/BootstrapPasswort.tsx
@@ -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 (
+
+
+ setBenutzer(e.target.value)}
+ autoComplete="off"
+ mono
+ hinweis="root oder ein Benutzer mit sudo"
+ />
+ setPasswort(e.target.value)}
+ autoComplete="off"
+ />
+
+ );
+}
diff --git a/src/app/(app)/hosts/[id]/HostKeyAktionen.tsx b/src/app/(app)/hosts/[id]/HostKeyAktionen.tsx
index 0086179..f8fdb7d 100644
--- a/src/app/(app)/hosts/[id]/HostKeyAktionen.tsx
+++ b/src/app/(app)/hosts/[id]/HostKeyAktionen.tsx
@@ -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 (
+ }
+ />
+ );
}
diff --git a/src/app/(app)/hosts/[id]/HostKeyEntscheidung.tsx b/src/app/(app)/hosts/[id]/HostKeyEntscheidung.tsx
new file mode 100644
index 0000000..72f5f9b
--- /dev/null
+++ b/src/app/(app)/hosts/[id]/HostKeyEntscheidung.tsx
@@ -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();
+ 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 (
+
+ {ersetztVertrauten ? (
+
+
+
+
Dieser Key weicht vom vertrauten Host-Key ab.
+
+ Nur annehmen, wenn sicher ist, dass der Host neu installiert oder sein Host-Key bewusst
+ getauscht wurde. Sonst kann jemand die Verbindung abfangen.
+
+
+
+
+ ) : (
+
+ Vergleichen Sie den Fingerprint am Host, z. B. mit{" "}
+
+ ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
+
+ .
+
+ )}
+
+
+
+
+ {fehler && (
+
+ {fehler}
+
+ )}
+
+ );
+}
diff --git a/src/app/(app)/hosts/[id]/page.tsx b/src/app/(app)/hosts/[id]/page.tsx
index 368720a..90fa9f8 100644
--- a/src/app/(app)/hosts/[id]/page.tsx
+++ b/src/app/(app)/hosts/[id]/page.tsx
@@ -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);
if (eintraege.length === 0) return null;
+ const hinweise = sshdHinweise(befund as Record);
return (