Merge branch 'phase/009-010-oberflaeche'
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
@@ -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).
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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$/);
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user