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" + /> +
+ } + 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; + }} + /> +
+ ); +} 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 (

sshd-Befund

@@ -63,6 +67,20 @@ function SshdBefund({ befund }: { befund: unknown }) {
))} + {hinweise.length > 0 && ( +
    + {hinweise.map((h) => ( +
  • + {h.stufe === "info" ? "ℹ" : "▲"} {h.text} +
  • + ))} +
+ )} ); } @@ -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} /> @@ -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={ - + } > {host.hostSchluessel.length === 0 ? ( @@ -184,6 +200,15 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri + {k.status === "AUSSTEHEND" && darfEntscheiden && ( + v.status === "VERTRAUT" && v.algorithmus === k.algorithmus, + )} + /> + )} ))} diff --git a/src/components/ui/MonoBlock.tsx b/src/components/ui/MonoBlock.tsx new file mode 100644 index 0000000..a4e71cc --- /dev/null +++ b/src/components/ui/MonoBlock.tsx @@ -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 ( +
+ +
+        {wert}
+      
+
+ ); +} diff --git a/src/lib/sshd-hinweise.ts b/src/lib/sshd-hinweise.ts new file mode 100644 index 0000000..c942bcf --- /dev/null +++ b/src/lib/sshd-hinweise.ts @@ -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 | 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; +} diff --git a/tests/e2e/host-einrichten.spec.ts b/tests/e2e/host-einrichten.spec.ts new file mode 100644 index 0000000..5a28ac9 --- /dev/null +++ b/tests/e2e/host-einrichten.spec.ts @@ -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$/); +}); diff --git a/tests/sshd-hinweise.test.ts b/tests/sshd-hinweise.test.ts new file mode 100644 index 0000000..a780be9 --- /dev/null +++ b/tests/sshd-hinweise.test.ts @@ -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([]); + }); +});