Merge branch 'phase/009-010-oberflaeche'

This commit is contained in:
2026-10-12 10:00:47 +02:00
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([]);
});
});