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
Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

+12 -8
View File
@@ -1,23 +1,27 @@
# #009 Host-Key-Prüfung (TOFU) und Erreichbarkeit # #009 Host-Key-Prüfung (TOFU) und Erreichbarkeit
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.1.0 **Meilenstein:** v0.1.0
**Labels:** sicherheit **Labels:** sicherheit
**Geschätzt:** 3 h **Tatsächlich:** – **Geschätzt:** 3 h **Tatsächlich:** 0 h 25 min
## Ziel ## Ziel
Beim ersten Kontakt wird der Host-Key angezeigt und bestätigt; danach strikte Prüfung, Änderung = Warnung. Beim ersten Kontakt wird der Host-Key angezeigt und bestätigt; danach strikte Prüfung, Änderung = Warnung.
## Aufgaben ## Aufgaben
- [ ] Vorgang „Host-Key abrufen“ (ohne Anmeldung) - [x] Vorgang „Host-Key abrufen“ (ohne Anmeldung)
- [ ] Bestätigen/Ablehnen mit Protokolleintrag - [x] Bestätigen/Ablehnen mit Protokolleintrag
- [ ] `hostVerifier` in ssh2 gegen gespeicherten Key - [x] `hostVerifier` in ssh2 gegen gespeicherten Key
- [ ] Geänderter Host-Key: Status GEAENDERT, keine Verbindung, deutliche Warnung - [x] Geänderter Host-Key: Status GEAENDERT, keine Verbindung, deutliche Warnung
- [ ] Erreichbarkeitsprüfung - [x] Erreichbarkeitsprüfung
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Integrationstest: geänderter Host-Key wird abgelehnt - [x] Integrationstest: geänderter Host-Key wird abgelehnt
## Abhängigkeiten ## Abhängigkeiten
#006, #007 #006, #007
## Notizen / Entscheidungen ## 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).
+15 -9
View File
@@ -1,24 +1,30 @@
# #010 Bootstrap neuer Hosts # #010 Bootstrap neuer Hosts
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.1.0 **Meilenstein:** v0.1.0
**Labels:** backend, ui **Labels:** backend, ui
**Geschätzt:** 4 h **Tatsächlich:** – **Geschätzt:** 4 h **Tatsächlich:** 0 h 35 min
## Ziel ## Ziel
Deployment-Key auf neuen Host bringen: einmalige Passwortanmeldung (nie gespeichert) oder Copy-Paste-Befehl. Dienstbenutzer `sshkv` + sudoers. Deployment-Key auf neuen Host bringen: einmalige Passwortanmeldung (nie gespeichert) oder Copy-Paste-Befehl. Dienstbenutzer `sshkv` + sudoers.
## Aufgaben ## Aufgaben
- [ ] Einmal-Passwort nur im Arbeitsspeicher des Workers, verschlüsselt im Job, danach gelöscht - [x] 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 - [x] Bootstrap-Skript: Benutzer `sshkv`, `/etc/sudoers.d/sshkv` (geprüft mit `visudo -c`), Deployment-Key in verwaltetem Block
- [ ] Alternative: Befehl zum Kopieren - [x] Alternative: Befehl zum Kopieren
- [ ] Prüfung nach Bootstrap: Anmeldung per Schlüssel, sudo ohne Passwort - [x] Prüfung nach Bootstrap: Anmeldung per Schlüssel, sudo ohne Passwort
- [ ] sshd-Check: `PermitRootLogin`, `PasswordAuthentication` (nur Anzeige) - [x] sshd-Check: `PermitRootLogin`, `PasswordAuthentication` (nur Anzeige)
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Integrationstest gegen Test-Container: Bootstrap per Passwort und per Befehl - [x] Integrationstest gegen Test-Container: Bootstrap per Passwort und per Befehl
- [ ] Passwort taucht nirgends in DB, Logs oder Protokoll auf - [x] Passwort taucht nirgends in DB, Logs oder Protokoll auf
## Abhängigkeiten ## Abhängigkeiten
#009 #009
## Notizen / Entscheidungen ## 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.
+64 -15
View File
@@ -1,19 +1,68 @@
export type BootstrapAktionenProps = { import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
readonly hostId: string; import { MonoBlock } from "@/components/ui/MonoBlock";
/** Recht `hostsVerwalten` – serverseitig per `darf()` ermittelt. */ import { bootstrapPruefenAktion } from "@/server/actions/bootstrap";
readonly darfVerwalten: boolean; import { erreichbarkeitPruefenAktion } from "@/server/actions/hostkeys";
/** `bootstrapAm` ist gesetzt: sshkv arbeitet bereits schluesselbasiert. */ import { BootstrapPasswort } from "./BootstrapPasswort";
readonly eingerichtet: boolean;
/** Mindestens ein vertrauter Host-Key – Voraussetzung fuer jede Verbindung. */
readonly hostKeyVertraut: boolean;
};
/** /**
* Platzhalter fuer „Zugang einrichten“ (Bootstrap, #010). Die Detailseite rendert diese * Bootstrap und Verbindungspruefung im Info-Bereich des Hosts (#010). Zwei Wege zum ersten
* Komponente unter der Info-Leiste; #010 ersetzt nur diese Datei. * Zugang: einmaliges Passwort (wird nie gespeichert) oder Befehl zum Kopieren.
*/ */
export function BootstrapAktionen(props: BootstrapAktionenProps) { export function BootstrapAktionen({
// Die Props stehen schon fest, damit die Detailseite bis #009/#010 unveraendert bleibt. hostId,
void props; darfVerwalten,
return null; 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 type { HostKeyZusammenfassung } from "@/lib/hosts";
import { hostKeyAbrufenAktion } from "@/server/actions/hostkeys";
export type HostKeyAktionenProps = { export type HostKeyAktionenProps = {
readonly hostId: string; readonly hostId: string;
@@ -8,12 +11,15 @@ export type HostKeyAktionenProps = {
readonly status: HostKeyZusammenfassung; readonly status: HostKeyZusammenfassung;
}; };
/** /** „Host-Key abrufen“ im Kopf der Karte (#009). Entschieden wird je Zeile (HostKeyEntscheidung). */
* Platzhalter fuer „Host-Key abrufen“ / „Bestätigen“ / „Ablehnen“ (#009). Die Detailseite export function HostKeyAktionen({ hostId, darfEntscheiden, status }: HostKeyAktionenProps) {
* rendert diese Komponente im Kopf der Karte „Host-Key“; #009 ersetzt nur diese Datei. if (!darfEntscheiden) return null;
*/ return (
export function HostKeyAktionen(props: HostKeyAktionenProps) { <VorgangsKnopf
// Die Props stehen schon fest, damit die Detailseite bis #009/#010 unveraendert bleibt. aktion={hostKeyAbrufenAktion.bind(null, hostId)}
void props; beschriftung={status.zustand === "keiner" ? "Host-Key abrufen" : "Erneut abrufen"}
return null; 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 { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { hostDetail, type HostDetail } from "@/server/queries/hosts"; import { hostDetail, type HostDetail } from "@/server/queries/hosts";
import { BootstrapAktionen } from "./BootstrapAktionen"; import { BootstrapAktionen } from "./BootstrapAktionen";
import { sshdHinweise } from "@/lib/sshd-hinweise";
import { HostKeyAktionen } from "./HostKeyAktionen"; import { HostKeyAktionen } from "./HostKeyAktionen";
import { HostKeyEntscheidung } from "./HostKeyEntscheidung";
import { bootstrapBefehlFuer } from "@/server/queries/bootstrap";
export const metadata: Metadata = { title: "Host" }; 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; if (!befund || typeof befund !== "object" || Array.isArray(befund)) return null;
const eintraege = Object.entries(befund as Record<string, unknown>); const eintraege = Object.entries(befund as Record<string, unknown>);
if (eintraege.length === 0) return null; if (eintraege.length === 0) return null;
const hinweise = sshdHinweise(befund as Record<string, unknown>);
return ( return (
<div className="border-border mt-5 border-t pt-4"> <div className="border-border mt-5 border-t pt-4">
<p className="text-muted text-[13px] font-medium">sshd-Befund</p> <p className="text-muted text-[13px] font-medium">sshd-Befund</p>
@@ -63,6 +67,20 @@ function SshdBefund({ befund }: { befund: unknown }) {
</div> </div>
))} ))}
</dl> </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> </div>
); );
} }
@@ -76,6 +94,7 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri
const archiviert = host.archiviertAm !== null; const archiviert = host.archiviertAm !== null;
const verwalten = darf(benutzer.rolle, "hostsVerwalten") && !archiviert; const verwalten = darf(benutzer.rolle, "hostsVerwalten") && !archiviert;
const darfEntscheiden = darf(benutzer.rolle, "hostKeyEntscheiden") && !archiviert;
const jetzt = new Date(); const jetzt = new Date();
return ( return (
@@ -142,6 +161,7 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri
darfVerwalten={verwalten} darfVerwalten={verwalten}
eingerichtet={host.bootstrapAm !== null} eingerichtet={host.bootstrapAm !== null}
hostKeyVertraut={host.hostKey.vertraut > 0} hostKeyVertraut={host.hostKey.vertraut > 0}
befehl={verwalten && !host.bootstrapAm ? await bootstrapBefehlFuer(host.id) : undefined}
/> />
</Karte> </Karte>
@@ -149,11 +169,7 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri
titel="Host-Key" titel="Host-Key"
untertitel="Fingerprints, gegen die sshkv jede Verbindung prüft" untertitel="Fingerprints, gegen die sshkv jede Verbindung prüft"
aktionen={ aktionen={
<HostKeyAktionen <HostKeyAktionen hostId={host.id} darfEntscheiden={darfEntscheiden} status={host.hostKey} />
hostId={host.id}
darfEntscheiden={darf(benutzer.rolle, "hostKeyEntscheiden") && !archiviert}
status={host.hostKey}
/>
} }
> >
{host.hostSchluessel.length === 0 ? ( {host.hostSchluessel.length === 0 ? (
@@ -184,6 +200,15 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri
<span className="hidden md:block"> <span className="hidden md:block">
<HostSchluesselStatusBadge status={k.status} /> <HostSchluesselStatusBadge status={k.status} />
</span> </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> </li>
))} ))}
</ul> </ul>
+45
View File
@@ -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>
);
}
+39
View File
@@ -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;
}
+72
View File
@@ -0,0 +1,72 @@
import { execFileSync } from "node:child_process";
import { expect, test } from "@playwright/test";
import "dotenv/config";
/**
* Einen neuen Host in Betrieb nehmen (#008–#010), im gebauten Stand mit laufendem Worker
* gegen das Testziel B aus docker-compose.test.yml:
* Host anlegen → Host-Key abrufen → vertrauen → Bootstrap per Passwort → Verbindung pruefen.
* Laeuft gegen die Entwicklungsdatenbank; der Host heisst zz-e2e-… und wird am Ende archiviert.
*/
const ZIEL_PORT = (process.env["SSHKV_TEST_ZIEL_B"] ?? "127.0.0.1:2222").split(":")[1] ?? "2222";
const ROOT_PASSWORT = process.env["SSHKV_TEST_ROOT_PASSWORT"] || "sshkv-test";
test.setTimeout(120_000);
test.beforeAll(() => {
// Testziel ohne Dienstbenutzer, damit der Bootstrap wirklich etwas zu tun hat.
execFileSync("docker", [
"compose",
"-f",
"docker-compose.test.yml",
"exec",
"-T",
"ziel-b",
"sh",
"-c",
"userdel -r sshkv 2>/dev/null; rm -f /etc/sudoers.d/sshkv; true",
]);
});
test("neuen Host einrichten", async ({ page }) => {
const name = `zz-e2e-${Date.now().toString(36)}`;
await page.goto("/anmelden");
await page.getByRole("link", { name: /Mit Mock-OIDC anmelden/ }).click();
await page.locator('button[data-rolle="admin"]').click();
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
await page.goto("/hosts/neu");
await page.getByLabel("Name").fill(name);
await page.getByLabel("Adresse").fill("127.0.0.1");
await page.getByLabel("Port").fill(ZIEL_PORT);
await page.getByRole("button", { name: "Host anlegen" }).click();
await expect(page.getByRole("heading", { name })).toBeVisible();
await page.getByRole("button", { name: "Host-Key abrufen" }).click();
await expect(page.getByRole("button", { name: "Fingerprint stimmt – vertrauen" })).toBeVisible({
timeout: 30_000,
});
await page.getByRole("button", { name: "Fingerprint stimmt – vertrauen" }).click();
await expect(page.getByText("Einmalig per Passwort")).toBeVisible({ timeout: 15_000 });
await expect(page.getByLabel("Bootstrap-Befehl", { exact: true })).toContainText("SSHKV_BOOTSTRAP");
await page.getByLabel("Passwort", { exact: true }).fill(ROOT_PASSWORT);
await page.getByRole("button", { name: "Zugang einrichten" }).click();
await expect(page.getByRole("button", { name: "Verbindung prüfen" })).toBeVisible({ timeout: 60_000 });
await expect(page.getByText("noch nicht eingerichtet")).toHaveCount(0);
await expect(page.getByText(/Empfohlen: PermitRootLogin prohibit-password/)).toBeVisible();
const pruefen = page.getByRole("button", { name: "Verbindung prüfen" });
await pruefen.click();
// Gezielt im Block dieses Knopfs – der Host-Key-Knopf zeigt sein eigenes „Erledigt.“.
await expect(pruefen.locator("..").getByText("Erledigt.")).toBeVisible({ timeout: 30_000 });
await page.screenshot({ path: "docs/anleitung/bilder/host-eingerichtet-desktop.png", fullPage: true });
// Aufraeumen: archivieren, damit die Liste nicht waechst.
await page.getByRole("button", { name: "Host archivieren" }).click();
await page.getByRole("dialog").getByRole("button", { name: "Archivieren", exact: true }).click();
await expect(page).toHaveURL(/\/hosts$/);
});
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { sshdHinweise } from "@/lib/sshd-hinweise";
describe("sshd-Hinweise (#010)", () => {
it("empfiehlt prohibit-password, wenn root per Passwort darf", () => {
expect(sshdHinweise({ permitRootLogin: "yes" })[0]).toMatchObject({ stufe: "warnung" });
expect(sshdHinweise({ permitRootLogin: "without-password" })).toEqual([]);
});
it("warnt, wenn sshd authorized_keys gar nicht liest", () => {
expect(sshdHinweise({ authorizedKeysFile: ".ssh/authorized_keys .ssh/authorized_keys2" })).toEqual([]);
expect(sshdHinweise({ authorizedKeysFile: "/etc/ssh/keys/%u" })[0]).toMatchObject({ stufe: "gefahr" });
});
it("kommt mit fehlenden Angaben zurecht", () => {
expect(sshdHinweise(null)).toEqual([]);
expect(sshdHinweise({})).toEqual([]);
});
});