Merge branch 'phase/018-020-assistent'

This commit is contained in:
2026-10-12 11:19:33 +02:00
16 changed files with 1144 additions and 2 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

@@ -0,0 +1,10 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "VorgangsArt" ADD VALUE 'ZIELKONTO_ANLEGEN';
ALTER TYPE "VorgangsArt" ADD VALUE 'ZIELKONTO_ENTFERNEN';
+4
View File
@@ -110,6 +110,10 @@ enum VorgangsArt {
SCHLUESSEL_IMPORTIEREN
/// Gespeicherten Private Key endgueltig verwerfen (#014).
PRIVATE_KEY_LOESCHEN
/// Zielbenutzer anlegen und sudoers setzen (#018).
ZIELKONTO_ANLEGEN
/// sudoers und – falls von sshkv angelegt – den Benutzer entfernen (#018, #024).
ZIELKONTO_ENTFERNEN
}
enum VorgangsStatus {
@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import { Leerzustand } from "@/components/ui/Leerzustand";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
export const metadata: Metadata = { title: "Bestätigt" };
/** Ziel der Bestaetigung aus dem Assistenten (#020): der Assistent wartet im anderen Tab. */
export default async function BestaetigtSeite() {
await sitzungErforderlich();
return (
<Leerzustand
titel="Anmeldung bestätigt"
text="Die Bestätigung gilt fünf Minuten. Diesen Tab schließen und im Assistenten „Fortsetzen“ wählen."
/>
);
}
+27
View File
@@ -0,0 +1,27 @@
import type { Metadata } from "next";
import { Assistent } from "@/components/assistent/Assistent";
import { Breadcrumb } from "@/components/ui/Breadcrumb";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { assistentDaten } from "@/server/queries/assistent";
import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
export const metadata: Metadata = { title: "Host-zu-Host-Assistent" };
/**
* Host-zu-Host-Assistent (#020): ein Schluesselpaar fuer genau einen Zweck, Private Key auf
* Host A, Public Key auf Host B, Verbindungstest. Nur Admins – es wird ein Private Key ausgerollt.
*/
export default async function AssistentSeite() {
const { benutzer } = await sitzungErforderlich("privateKeyAusrollen", "/assistent");
const daten = await assistentDaten();
return (
<div className="flex flex-col gap-6 sm:gap-7">
<Seitenkopf
oben={<Breadcrumb pfad={[{ titel: "Übersicht", href: "/" }, { titel: "Host-zu-Host-Assistent" }]} />}
titel="Host-zu-Host-Assistent"
untertitel="Host A soll sich per SSH an Host B anmelden – Schlüssel, Ausrollen und Test in einem Durchgang."
/>
<Assistent daten={daten} bestaetigt={bestaetigungFrisch(benutzer.bestaetigtAm)} />
</div>
);
}
+1 -1
View File
@@ -53,7 +53,7 @@ export default async function Uebersicht() {
aktionen={
<>
<Button icon={<RefreshCw className="size-4" />}>Jetzt abgleichen</Button>
<Button href="/zuweisungen" icon={<Route className="size-4" />}>
<Button href="/assistent" icon={<Route className="size-4" />}>
Host-zu-Host-Assistent
</Button>
<Button
+637
View File
@@ -0,0 +1,637 @@
"use client";
import { ArrowLeft, ArrowRight, Check, ExternalLink, Play } from "lucide-react";
import { useState } from "react";
import { clsx } from "clsx";
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
import { Ankreuzfeld, Button, Feld, Formularfehler, Karte } from "@/components/ui";
import { Auswahlliste, Textbereich } from "@/components/ui/Feld";
import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
import { istIpAdresse, platzhalterErsetzen } from "@/lib/vorlagen";
import { sudoersInhalt } from "@/lib/ssh/zielkonto";
import {
assistentErzeugenAktion,
assistentZielkontoAktion,
assistentZuweisenAktion,
type SchrittAntwort,
} from "@/server/actions/assistent";
import { verbindungTestenAktion } from "@/server/actions/verwendungen";
import type { AssistentDaten } from "@/server/queries/assistent";
/**
* Host-zu-Host-Assistent (#020). Vier Schritte Eingabe, dann eine Zusammenfassung, dann die
* Ausfuehrung: Schluessel erzeugen → (Zielbenutzer anlegen) → zuweisen → Dry-Run und Ausrollen →
* Verbindungstest. Die Ausfuehrung ist wiederaufnehmbar: was schon erledigt ist, wird nicht
* wiederholt – wichtig, wenn mittendrin eine erneute Bestaetigung noetig wird.
*/
type Werte = {
vorlageId: string;
zweck: string;
ablaufAm: string;
quelleHostId: string;
quelleBenutzer: string;
pfad: string;
zielHostId: string;
zielBenutzer: string;
anlegen: boolean;
sudoModus: "KEINER" | "BEFEHLE" | "VOLL";
sudoBefehle: string;
optionen: string;
quellIp: string;
alias: string;
hostName: string;
port: string;
knownHosts: boolean;
fingerprintGeprueft: boolean;
};
const LEER: Werte = {
vorlageId: "",
zweck: "",
ablaufAm: "",
quelleHostId: "",
quelleBenutzer: "root",
pfad: "",
zielHostId: "",
zielBenutzer: "root",
anlegen: false,
sudoModus: "KEINER",
sudoBefehle: "",
optionen: "",
quellIp: "",
alias: "",
hostName: "",
port: "22",
knownHosts: true,
fingerprintGeprueft: false,
};
const SCHRITTE = ["Vorlage & Zweck", "Quelle (Host A)", "Ziel (Host B)", "Prüfen", "Ausrollen"] as const;
type Ablauf = {
schluesselId?: string;
zielkontoFertig?: boolean;
privatVerwendungId?: string;
laeuft?: string;
fehler?: string;
bestaetigen?: boolean;
};
function datumIn(tage: number): string {
return new Date(Date.now() + tage * 86_400_000).toISOString().slice(0, 10);
}
function kurz(text: string): string {
return (
text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 40) || "zugang"
);
}
/** Wartet auf das Ende eines Vorgangs (fuer die Kette im Klick, nicht als Hook). */
async function warten(
vorgangId: string,
): Promise<{ status: string; fehler: string | null; ergebnis: unknown }> {
for (;;) {
const antwort = await fetch(`/api/vorgaenge/${vorgangId}`, { cache: "no-store" });
if (!antwort.ok) throw new Error("Status des Vorgangs nicht abrufbar.");
const stand = (await antwort.json()) as { status: string; fehler: string | null; ergebnis: unknown };
if (stand.status !== "WARTEND" && stand.status !== "LAEUFT") return stand;
await new Promise((weiter) => setTimeout(weiter, 800));
}
}
export function Assistent({ daten, bestaetigt }: { daten: AssistentDaten; bestaetigt: boolean }) {
const [schritt, setSchritt] = useState(0);
const [w, setW] = useState<Werte>(LEER);
const [ablauf, setAblauf] = useState<Ablauf>({});
const setze = <K extends keyof Werte>(feld: K, wert: Werte[K]) => setW((alt) => ({ ...alt, [feld]: wert }));
const vorlage = daten.vorlagen.find((v) => v.id === w.vorlageId);
const quelle = daten.hosts.find((h) => h.id === w.quelleHostId);
const ziel = daten.hosts.find((h) => h.id === w.zielHostId);
function vorlageWaehlen(id: string) {
const v = daten.vorlagen.find((x) => x.id === id);
setW((alt) => ({
...alt,
vorlageId: id,
...(v
? {
zielBenutzer: v.zielbenutzer,
anlegen: v.zielbenutzerAnlegen,
sudoModus: v.sudoModus,
sudoBefehle: v.sudoBefehle.join("\n"),
knownHosts: v.knownHostsEintragen,
ablaufAm: v.standardLaufzeitTage ? datumIn(v.standardLaufzeitTage) : "",
}
: {}),
}));
}
/** Beim Wechsel zu „Ziel“: Muster der Vorlage mit A und B aufloesen (nur leere Felder). */
function zielVorbelegen(zielId: string) {
const b = daten.hosts.find((h) => h.id === zielId);
const quellIp = w.quellIp || (quelle && istIpAdresse(quelle.adresse) ? quelle.adresse : "");
const werte = {
quellHost: quelle?.name,
quellHostIp: quellIp,
zielHost: b?.name,
zielHostIp: b?.adresse,
};
const aufloesen = (muster: string | null | undefined) => {
if (!muster) return "";
try {
return platzhalterErsetzen(muster, werte);
} catch {
return muster;
}
};
setW((alt) => ({
...alt,
zielHostId: zielId,
quellIp,
optionen: alt.optionen || aufloesen(vorlage?.authorizedKeysOptionen),
alias: alt.alias || aufloesen(vorlage?.sshConfigAlias) || (b ? b.name : ""),
hostName: b ? b.adresse : "",
port: b ? String(b.port) : "22",
pfad: alt.pfad || `~/.ssh/id_ed25519_${kurz(alt.zweck)}`,
fingerprintGeprueft: false,
}));
}
const offenePlatzhalter = /\{\{/.test(w.optionen) || /\{\{/.test(w.alias);
let sudoVorschau: string | null = null;
let sudoFehler: string | undefined;
if (w.anlegen) {
try {
sudoVorschau = sudoersInhalt(
w.zielBenutzer,
w.sudoModus,
w.sudoBefehle.split("\n").filter((z) => z.trim()),
);
} catch (fehler) {
sudoFehler = (fehler as Error).message;
}
}
const schrittOk = [
w.zweck.trim().length > 0,
Boolean(w.quelleHostId && w.quelleBenutzer),
Boolean(w.zielHostId && w.zielHostId !== w.quelleHostId && w.zielBenutzer && w.alias && w.hostName) &&
!offenePlatzhalter &&
!sudoFehler &&
(!w.knownHosts || w.fingerprintGeprueft),
true,
];
async function ausfuehren() {
let stand = { ...ablauf, fehler: undefined, bestaetigen: false } as Ablauf;
const melden = (neu: Partial<Ablauf>) => {
stand = { ...stand, ...neu };
setAblauf(stand);
};
const pruefen = (antwort: SchrittAntwort) => {
if (antwort.fehler || antwort.bestaetigen) {
melden({ laeuft: undefined, fehler: antwort.fehler, bestaetigen: Boolean(antwort.bestaetigen) });
return false;
}
return true;
};
try {
if (!stand.schluesselId) {
melden({ laeuft: "Schlüsselpaar wird erzeugt …" });
const antwort = await assistentErzeugenAktion({
name: w.zweck.trim(),
vorlageId: w.vorlageId || null,
ablaufAm: w.ablaufAm || null,
});
if (!pruefen(antwort)) return;
const ergebnis = await warten(antwort.vorgangId!);
if (ergebnis.status !== "ERLEDIGT")
return melden({ laeuft: undefined, fehler: ergebnis.fehler ?? "Erzeugen fehlgeschlagen." });
melden({ schluesselId: (ergebnis.ergebnis as { schluesselId: string }).schluesselId });
}
if (w.anlegen && !stand.zielkontoFertig) {
melden({ laeuft: `Benutzer ${w.zielBenutzer} wird auf ${ziel?.name} eingerichtet …` });
const antwort = await assistentZielkontoAktion({
hostId: w.zielHostId,
benutzer: w.zielBenutzer,
modus: w.sudoModus,
befehle: w.sudoBefehle.split("\n").filter((z) => z.trim()),
});
if (!pruefen(antwort)) return;
const ergebnis = await warten(antwort.vorgangId!);
if (ergebnis.status !== "ERLEDIGT")
return melden({ laeuft: undefined, fehler: ergebnis.fehler ?? "Zielkonto fehlgeschlagen." });
melden({ zielkontoFertig: true });
}
if (!stand.privatVerwendungId) {
melden({ laeuft: "Zuweisungen werden angelegt …" });
const antwort = await assistentZuweisenAktion({
schluesselId: stand.schluesselId!,
quelle: { hostId: w.quelleHostId, benutzer: w.quelleBenutzer, pfad: w.pfad },
ziel: { hostId: w.zielHostId, benutzer: w.zielBenutzer, optionen: w.optionen.trim() || null },
ssh: { alias: w.alias, hostName: w.hostName, port: Number(w.port), knownHosts: w.knownHosts },
});
if (!pruefen(antwort)) return;
melden({ privatVerwendungId: antwort.privatVerwendungId });
}
melden({ laeuft: undefined });
setSchritt(4);
} catch (fehler) {
melden({ laeuft: undefined, fehler: (fehler as Error).message });
}
}
return (
<div className="flex flex-col gap-6">
<ol className="flex flex-wrap gap-2" aria-label="Schritte">
{SCHRITTE.map((titel, index) => (
<li
key={titel}
aria-current={index === schritt ? "step" : undefined}
className={clsx(
"rounded-box flex min-h-11 items-center gap-2 border px-3 text-[13.5px]",
index === schritt
? "border-accent text-accent bg-raised font-medium"
: index < schritt
? "border-border text-text"
: "border-border text-muted",
)}
>
<span className="font-mono">
{index < schritt ? <Check className="size-4" aria-hidden /> : index + 1}
</span>
{titel}
</li>
))}
</ol>
{schritt === 0 && (
<Karte
titel="Vorlage & Zweck"
untertitel="Eine Vorlage füllt Zielbenutzer, Einschränkungen und Ablauf vor."
>
<div className="flex flex-col gap-4">
<Auswahlliste
beschriftung="Vorlage"
name="vorlageId"
value={w.vorlageId}
onChange={(e) => vorlageWaehlen(e.target.value)}
>
<option value="">– ohne Vorlage –</option>
{daten.vorlagen.map((v) => (
<option key={v.id} value={v.id}>
{v.name}
</option>
))}
</Auswahlliste>
{vorlage?.beschreibung && <p className="text-muted text-[14px]">{vorlage.beschreibung}</p>}
<Feld
beschriftung="Zweck"
name="zweck"
value={w.zweck}
onChange={(e) => setze("zweck", e.target.value)}
placeholder="Backup srv-backup01 → srv-pve01"
required
/>
<Feld
beschriftung="Ablaufdatum (optional)"
name="ablaufAm"
type="date"
value={w.ablaufAm}
onChange={(e) => setze("ablaufAm", e.target.value)}
hinweis="Projektbezogene Zugänge sollten ablaufen."
/>
</div>
</Karte>
)}
{schritt === 1 && (
<Karte
titel="Quelle – Host A"
untertitel="Dieser Host bekommt den Private Key und meldet sich später an."
>
<div className="flex flex-col gap-4">
<Auswahlliste
beschriftung="Host A"
name="quelleHostId"
value={w.quelleHostId}
onChange={(e) => setze("quelleHostId", e.target.value)}
>
<option value="">Host wählen …</option>
{daten.hosts.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</Auswahlliste>
<div className="grid gap-4 sm:grid-cols-2">
<Feld
beschriftung="Benutzer auf A"
name="quelleBenutzer"
value={w.quelleBenutzer}
onChange={(e) => setze("quelleBenutzer", e.target.value)}
mono
/>
<Feld
beschriftung="Pfad des Private Keys"
name="pfad"
value={w.pfad || `~/.ssh/id_ed25519_${kurz(w.zweck)}`}
onChange={(e) => setze("pfad", e.target.value)}
mono
/>
</div>
</div>
</Karte>
)}
{schritt === 2 && (
<Karte titel="Ziel – Host B" untertitel="Dieser Host nimmt den Public Key an.">
<div className="flex flex-col gap-4">
<Auswahlliste
beschriftung="Host B"
name="zielHostId"
value={w.zielHostId}
onChange={(e) => zielVorbelegen(e.target.value)}
>
<option value="">Host wählen …</option>
{daten.hosts
.filter((h) => h.id !== w.quelleHostId)
.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</Auswahlliste>
<Feld
beschriftung="Benutzer auf B"
name="zielBenutzer"
value={w.zielBenutzer}
onChange={(e) => setze("zielBenutzer", e.target.value)}
mono
/>
{w.zielBenutzer === "root" && (
<p className="border-danger-border bg-danger-soft text-danger rounded-box border px-3 py-2.5 text-[13.5px]">
▲ Zugang als root: Wer den Private Key auf A nutzt – etwa ein KI-Agent wie Claude Code –, hat
auf B uneingeschränkten Zugriff. Für Deployments ist ein eigener Benutzer wie „deploy“ besser.
</p>
)}
<Ankreuzfeld
beschriftung={`Benutzer „${w.zielBenutzer}“ auf B anlegen bzw. pflegen`}
erklaerung="Mit Home und ~/.ssh; optional mit sudoers-Datei. Verlangt eine frische Bestätigung."
checked={w.anlegen}
onChange={(e) => setze("anlegen", e.target.checked)}
/>
{w.anlegen && (
<div className="border-border flex flex-col gap-3 border-l-2 pl-4">
<Auswahlliste
beschriftung="sudo"
name="sudoModus"
value={w.sudoModus}
onChange={(e) => setze("sudoModus", e.target.value as Werte["sudoModus"])}
>
<option value="KEINER">keine sudo-Rechte</option>
<option value="BEFEHLE">bestimmte Befehle</option>
<option value="VOLL">voller sudo-Zugriff</option>
</Auswahlliste>
{w.sudoModus === "BEFEHLE" && (
<Textbereich
beschriftung="Erlaubte Befehle (einer je Zeile, absoluter Pfad)"
name="sudoBefehle"
value={w.sudoBefehle}
onChange={(e) => setze("sudoBefehle", e.target.value)}
rows={3}
/>
)}
{sudoFehler && <p className="text-danger text-[13px]">{sudoFehler}</p>}
{sudoVorschau && (
<pre className="border-border bg-bg rounded-box overflow-x-auto border p-3 font-mono text-[12.5px]">
{sudoVorschau}
</pre>
)}
</div>
)}
{quelle && !istIpAdresse(quelle.adresse) && (
<Feld
beschriftung={`IP von ${quelle.name} (für from="…")`}
name="quellIp"
value={w.quellIp}
onChange={(e) => setze("quellIp", e.target.value)}
mono
hinweis="Die Adresse, unter der B den Host A sieht."
/>
)}
<Feld
beschriftung="Einschränkungen in authorized_keys"
name="optionen"
value={w.optionen}
onChange={(e) => setze("optionen", e.target.value)}
mono
placeholder={
quelle && istIpAdresse(quelle.adresse) ? `from="${quelle.adresse}"` : 'from="10.0.0.5"'
}
hinweis={
offenePlatzhalter
? "Es sind noch Platzhalter offen."
: 'Empfohlen: from="<IP von A>", damit der Schlüssel nur von dort funktioniert.'
}
/>
<div className="grid gap-4 sm:grid-cols-3">
<Feld
beschriftung="Alias auf A"
name="alias"
value={w.alias}
onChange={(e) => setze("alias", e.target.value)}
mono
/>
<Feld
beschriftung="HostName (aus Sicht von A)"
name="hostName"
value={w.hostName}
onChange={(e) => setze("hostName", e.target.value)}
mono
/>
<Feld
beschriftung="Port"
name="port"
type="number"
value={w.port}
onChange={(e) => setze("port", e.target.value)}
mono
/>
</div>
<Ankreuzfeld
beschriftung="Host-Key von B in known_hosts von A eintragen"
checked={w.knownHosts}
onChange={(e) => setze("knownHosts", e.target.checked)}
/>
{w.knownHosts && ziel && (
<div className="border-border rounded-box flex flex-col gap-2 border p-3">
{ziel.hostKeys.map((k) => (
<p key={k.fingerprint} className="font-mono text-[12.5px] break-all">
<span className="text-muted">{k.algorithmus}</span> {k.fingerprint}
</p>
))}
<Ankreuzfeld
beschriftung="Fingerprint von B ist bestätigt"
erklaerung="Derselbe Host-Key, dem sshkv beim Bootstrap von B vertraut hat."
checked={w.fingerprintGeprueft}
onChange={(e) => setze("fingerprintGeprueft", e.target.checked)}
/>
</div>
)}
</div>
</Karte>
)}
{schritt === 3 && (
<Karte
titel="Zusammenfassung"
untertitel="Noch ist nichts passiert. Ausgerollt wird erst nach dem Dry-Run."
>
<dl className="grid gap-x-6 gap-y-3 text-[14px] sm:grid-cols-[12rem_minmax(0,1fr)]">
<dt className="text-muted">Schlüssel</dt>
<dd>
ed25519 · {w.zweck}
{w.ablaufAm && (
<span className="text-accent">
{" "}
· läuft ab am {w.ablaufAm.split("-").reverse().join(".")}
</span>
)}
</dd>
<dt className="text-muted">Private Key</dt>
<dd className="font-mono text-[13px] break-all">
{w.quelleBenutzer}@{quelle?.name}:{w.pfad}
</dd>
<dt className="text-muted">~/.ssh/config auf A</dt>
<dd className="font-mono text-[13px] break-all">
Host {w.alias} → {w.zielBenutzer}@{w.hostName}:{w.port}
</dd>
<dt className="text-muted">Public Key</dt>
<dd className="font-mono text-[13px] break-all">
{w.optionen && `${w.optionen} · `}
{w.zielBenutzer}@{ziel?.name}
</dd>
{w.anlegen && (
<>
<dt className="text-muted">Zielbenutzer</dt>
<dd>
„{w.zielBenutzer}“ auf {ziel?.name} anlegen,{" "}
{w.sudoModus === "KEINER" ? (
"ohne sudo"
) : w.sudoModus === "VOLL" ? (
<span className="text-danger">voller sudo-Zugriff</span>
) : (
"sudo für bestimmte Befehle"
)}
</dd>
</>
)}
</dl>
{w.anlegen && !bestaetigt && !ablauf.bestaetigen && (
<p className="text-accent mt-4 text-[13.5px]">
Das Anlegen des Benutzers verlangt eine frische Bestätigung Ihrer Anmeldung – sshkv fragt beim
Ausführen danach.
</p>
)}
<div className="border-border mt-5 flex flex-col gap-3 border-t pt-4">
<Formularfehler text={ablauf.fehler} />
{ablauf.bestaetigen && (
<a
href="/bestaetigen?ziel=%2Fassistent%2Fbestaetigt"
target="_blank"
rel="noopener"
className="text-accent inline-flex min-h-11 items-center gap-2 text-[14px] font-medium"
>
<ExternalLink className="size-4" aria-hidden /> Anmeldung in neuem Tab bestätigen, dann hier
erneut ausführen
</a>
)}
{ablauf.laeuft && (
<p role="status" className="text-muted text-[14px]">
{ablauf.laeuft}
</p>
)}
<Button
variante="primaer"
icon={<Play className="size-4" />}
disabled={Boolean(ablauf.laeuft)}
onClick={() => void ausfuehren()}
>
{ablauf.schluesselId ? "Fortsetzen" : "Vorbereiten"}
</Button>
</div>
</Karte>
)}
{schritt === 4 && ablauf.schluesselId && (
<>
<Karte
titel="Dry-Run und Ausrollen"
untertitel="Prüfen Sie die Änderungen auf A und B, dann anwenden."
>
<AusrollenPanel auswahl={{ schluesselId: ablauf.schluesselId }} offen={2} />
</Karte>
<Karte
titel="Verbindung testen"
untertitel={`Auf ${quelle?.name} als ${w.quelleBenutzer}: ssh ${w.alias} true`}
>
{ablauf.privatVerwendungId && (
<VorgangsKnopf
aktion={verbindungTestenAktion.bind(null, ablauf.privatVerwendungId)}
beschriftung="Verbindung von A nach B testen"
variante="primaer"
/>
)}
<p className="text-muted mt-3 text-[13.5px]">
Zum Schlüssel:{" "}
<a className="text-accent" href={`/schluessel/${ablauf.schluesselId}`}>
{w.zweck}
</a>
</p>
</Karte>
</>
)}
{schritt < 3 && (
<div className="flex flex-wrap justify-between gap-3">
<Button
icon={<ArrowLeft className="size-4" />}
disabled={schritt === 0}
onClick={() => setSchritt(schritt - 1)}
>
Zurück
</Button>
<Button
variante="primaer"
icon={<ArrowRight className="size-4" />}
disabled={!schrittOk[schritt]}
onClick={() => {
if (schritt === 1 && !w.pfad) setze("pfad", `~/.ssh/id_ed25519_${kurz(w.zweck)}`);
setSchritt(schritt + 1);
}}
>
Weiter
</Button>
</div>
)}
{schritt === 3 && !ablauf.schluesselId && (
<div>
<Button
icon={<ArrowLeft className="size-4" />}
disabled={Boolean(ablauf.laeuft)}
onClick={() => setSchritt(2)}
>
Zurück
</Button>
</div>
)}
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
"use server";
import { ZielkontoFehler } from "@/lib/ssh/zielkonto";
import { z } from "@/lib/validierung";
import { akteurVon } from "@/server/actions/formular";
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { HostFehler } from "@/server/services/hosts";
import { erzeugenAnfordern } from "@/server/services/schluessel";
import {
VerwendungsFehler,
privateVerwendungAnlegen,
publicVerwendungAnlegen,
} from "@/server/services/verwendungen";
import { zielkontoAnfordern } from "@/server/services/zielkonten";
/**
* Schritte des Host-zu-Host-Assistenten (#020). Jeder Schritt ist eine eigene Aktion, die
* Oberflaeche verkettet sie und wartet dazwischen auf den Worker. So bleibt jede Aktion
* duenn, und jeder Schritt hat seine eigene Rechtepruefung.
*/
export type SchrittAntwort = {
readonly vorgangId?: string;
readonly privatVerwendungId?: string;
readonly fehler?: string;
/** Kritischer Schritt ohne frische Bestaetigung: dorthin, dann weiter. */
readonly bestaetigen?: boolean;
};
function alsAntwort(fehler: unknown): SchrittAntwort {
if (fehler instanceof BestaetigungFehler) return { bestaetigen: true, fehler: fehler.message };
if (
fehler instanceof RechteFehler ||
fehler instanceof VerwendungsFehler ||
fehler instanceof ZielkontoFehler ||
fehler instanceof HostFehler
) {
return { fehler: fehler.message };
}
throw fehler;
}
const id = z.string().min(1).max(64);
export async function assistentErzeugenAktion(eingabe: {
name: string;
vorlageId: string | null;
ablaufAm: string | null;
}): Promise<SchrittAntwort> {
try {
const { benutzer } = await rechtErforderlich("privateKeyAusrollen");
return {
vorgangId: await erzeugenAnfordern(
{
name: eingabe.name,
vorlageId: eingabe.vorlageId,
ablaufAm: eingabe.ablaufAm,
tags: ["host-zu-host"],
},
undefined,
akteurVon(benutzer),
),
};
} catch (fehler) {
return alsAntwort(fehler);
}
}
export async function assistentZielkontoAktion(eingabe: {
hostId: string;
benutzer: string;
modus: "KEINER" | "BEFEHLE" | "VOLL";
befehle: string[];
}): Promise<SchrittAntwort> {
try {
// Ein neuer Benutzer mit sudo ist so viel wert wie root: Admin mit frischer Bestaetigung.
const { benutzer } = await rechtErforderlich("zielkontenVerwalten");
return {
vorgangId: await zielkontoAnfordern({ ...eingabe, hostId: id.parse(eingabe.hostId) }, benutzer),
};
} catch (fehler) {
return alsAntwort(fehler);
}
}
export async function assistentZuweisenAktion(eingabe: {
schluesselId: string;
quelle: { hostId: string; benutzer: string; pfad: string };
ziel: { hostId: string; benutzer: string; optionen: string | null };
ssh: { alias: string; hostName: string; port: number; knownHosts: boolean };
}): Promise<SchrittAntwort> {
try {
const { benutzer } = await rechtErforderlich("privateKeyAusrollen");
const akteur = akteurVon(benutzer);
const privatVerwendungId = await privateVerwendungAnlegen(
{
schluesselId: id.parse(eingabe.schluesselId),
hostId: id.parse(eingabe.quelle.hostId),
zielbenutzer: eingabe.quelle.benutzer,
pfad: eingabe.quelle.pfad,
sshConfig: {
alias: eingabe.ssh.alias,
hostName: eingabe.ssh.hostName,
benutzer: eingabe.ziel.benutzer,
port: eingabe.ssh.port,
},
knownHostsFuer: eingabe.ssh.knownHosts ? [id.parse(eingabe.ziel.hostId)] : [],
},
akteur,
);
await publicVerwendungAnlegen(
{
schluesselId: eingabe.schluesselId,
hostId: id.parse(eingabe.ziel.hostId),
zielbenutzer: eingabe.ziel.benutzer,
optionen: eingabe.ziel.optionen,
},
akteur,
);
return { privatVerwendungId };
} catch (fehler) {
return alsAntwort(fehler);
}
}
+44
View File
@@ -0,0 +1,44 @@
import { prisma } from "@/lib/db";
/** Was der Host-zu-Host-Assistent zur Auswahl braucht (#020) – nur oeffentliche Angaben. */
export async function assistentDaten() {
const [hosts, vorlagen] = await Promise.all([
prisma.host.findMany({
where: { archiviertAm: null },
select: {
id: true,
name: true,
adresse: true,
port: true,
bootstrapAm: true,
hostSchluessel: { where: { status: "VERTRAUT" }, select: { algorithmus: true, fingerprint: true } },
},
orderBy: { name: "asc" },
}),
prisma.vorlage.findMany({
where: { archiviertAm: null, kategorie: "DIENST" },
select: {
id: true,
name: true,
beschreibung: true,
standardLaufzeitTage: true,
zielbenutzer: true,
zielbenutzerAnlegen: true,
sudoModus: true,
sudoBefehle: true,
authorizedKeysOptionen: true,
sshConfigAlias: true,
knownHostsEintragen: true,
},
orderBy: [{ eingebaut: "desc" }, { name: "asc" }],
}),
]);
return {
hosts: hosts
.filter((h) => h.bootstrapAm !== null)
.map((h) => ({ id: h.id, name: h.name, adresse: h.adresse, port: h.port, hostKeys: h.hostSchluessel })),
vorlagen,
};
}
export type AssistentDaten = Awaited<ReturnType<typeof assistentDaten>>;
+31
View File
@@ -0,0 +1,31 @@
import { BENUTZERNAME_MUSTER } from "@/lib/shell";
import { istGeschuetzt, sudoersInhalt, ZielkontoFehler, type SudoModus } from "@/lib/ssh/zielkonto";
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
/**
* Zielkonten anfordern (#018). Nur Admins mit frischer Bestaetigung (Recht
* zielkontenVerwalten) – ein neuer Benutzer mit sudo ist so viel wert wie root.
*/
export function zielkontoEingabePruefen(
benutzer: string,
modus: SudoModus,
befehle: readonly string[],
): void {
if (!BENUTZERNAME_MUSTER.test(benutzer)) throw new ZielkontoFehler("Ungültiger Benutzername.");
if (istGeschuetzt(benutzer))
throw new ZielkontoFehler(`„${benutzer}“ verwaltet sshkv nicht als Zielkonto.`);
sudoersInhalt(benutzer, modus, befehle);
}
export async function zielkontoAnfordern(
eingabe: { hostId: string; benutzer: string; modus: SudoModus; befehle: readonly string[] },
akteur: { id: string },
): Promise<string> {
zielkontoEingabePruefen(eingabe.benutzer, eingabe.modus, eingabe.befehle);
return vorgangEinreihen({
art: "ZIELKONTO_ANLEGEN",
angefordertVonId: akteur.id,
hostId: eingabe.hostId,
eingabe: { benutzer: eingabe.benutzer, modus: eingabe.modus, befehle: [...eingabe.befehle] },
});
}
+98
View File
@@ -0,0 +1,98 @@
import { prisma } from "@/lib/db";
import { sudoersDatei, sudoersInhalt, type SudoModus } from "@/lib/ssh/zielkonto";
import { protokollieren } from "@/server/services/protokoll";
import { VorgangsFehler, type Handler } from "@/worker/ausfuehren";
import { hostLaden } from "@/worker/ablaeufe/hostkey";
import { hostVerbinden } from "@/worker/ssh/host";
import { zielkontoAnwenden, zielkontoEntfernen } from "@/worker/ssh/zielkonto";
/** Vorgaenge ZIELKONTO_ANLEGEN und ZIELKONTO_ENTFERNEN (#018). */
type Eingabe = { benutzer?: unknown; modus?: unknown; befehle?: unknown };
function eingabeLesen(roh: unknown): { benutzer: string; modus: SudoModus; befehle: string[] } {
const e = (roh ?? {}) as Eingabe;
if (typeof e.benutzer !== "string") throw new VorgangsFehler("Kein Benutzer angegeben.");
const modus = (["KEINER", "BEFEHLE", "VOLL"] as const).find((m) => m === e.modus) ?? "KEINER";
const befehle = Array.isArray(e.befehle) ? e.befehle.filter((b): b is string => typeof b === "string") : [];
return { benutzer: e.benutzer, modus, befehle };
}
async function akteurVon(id: string | null) {
const benutzer = id
? await prisma.benutzer.findUnique({ where: { id }, select: { id: true, name: true } })
: null;
return benutzer ?? ("System" as const);
}
export const zielkontoAnlegenHandler: Handler = async ({ vorgang }) => {
const host = await hostLaden(vorgang.hostId);
const konto = eingabeLesen(vorgang.eingabe);
const verbindung = await hostVerbinden(host);
let angelegt: boolean;
try {
({ angelegt } = await zielkontoAnwenden(verbindung, host, konto));
} finally {
verbindung.end();
}
const inhalt = sudoersInhalt(konto.benutzer, konto.modus, konto.befehle);
const vorher = await prisma.zielkonto.findUnique({
where: { hostId_benutzername: { hostId: host.id, benutzername: konto.benutzer } },
});
await prisma.zielkonto.upsert({
where: { hostId_benutzername: { hostId: host.id, benutzername: konto.benutzer } },
create: {
hostId: host.id,
benutzername: konto.benutzer,
vonSshkvAngelegt: angelegt,
sudoModus: konto.modus,
sudoBefehle: konto.befehle,
sudoersDatei: inhalt ? sudoersDatei(konto.benutzer) : null,
sudoersInhalt: inhalt,
},
update: {
// Wer den Benutzer einmal angelegt hat, bleibt dabei – sonst loeschte ein Widerruf ihn nie.
vonSshkvAngelegt: (vorher?.vonSshkvAngelegt && !vorher.entferntAm) || angelegt,
sudoModus: konto.modus,
sudoBefehle: konto.befehle,
sudoersDatei: inhalt ? sudoersDatei(konto.benutzer) : null,
sudoersInhalt: inhalt,
entferntAm: null,
},
});
await protokollieren({
akteur: await akteurVon(vorgang.angefordertVonId),
aktion: angelegt ? "zielkonto.angelegt" : "zielkonto.geaendert",
ergebnis: "ERFOLG",
hostName: host.name,
zielbenutzer: konto.benutzer,
diff: inhalt ?? "(keine sudoers-Datei)",
details: { sudoModus: konto.modus },
});
return { ergebnis: { angelegt, benutzer: konto.benutzer } };
};
export const zielkontoEntfernenHandler: Handler = async ({ vorgang }) => {
const host = await hostLaden(vorgang.hostId);
const { benutzer } = eingabeLesen(vorgang.eingabe);
const konto = await prisma.zielkonto.findUnique({
where: { hostId_benutzername: { hostId: host.id, benutzername: benutzer } },
});
if (!konto || konto.entferntAm) throw new VorgangsFehler("Dieses Zielkonto verwaltet sshkv nicht (mehr).");
const verbindung = await hostVerbinden(host);
try {
// Den Benutzer selbst nur loeschen, wenn sshkv ihn angelegt hat.
await zielkontoEntfernen(verbindung, host, benutzer, konto.vonSshkvAngelegt);
} finally {
verbindung.end();
}
await prisma.zielkonto.update({ where: { id: konto.id }, data: { entferntAm: new Date() } });
await protokollieren({
akteur: await akteurVon(vorgang.angefordertVonId),
aktion: "zielkonto.entfernt",
ergebnis: "ERFOLG",
hostName: host.name,
zielbenutzer: benutzer,
details: { benutzerGeloescht: konto.vonSshkvAngelegt },
});
return { ergebnis: { benutzerGeloescht: konto.vonSshkvAngelegt } };
};
+3
View File
@@ -5,6 +5,7 @@ import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/
import { privateKeyAbholenHandler, privateKeyLoeschenHandler } from "@/worker/ablaeufe/privatekey";
import { schluesselErzeugenHandler, schluesselImportierenHandler } from "@/worker/ablaeufe/schluessel";
import { verbindungstestHandler } from "@/worker/ablaeufe/verbindungstest";
import { zielkontoAnlegenHandler, zielkontoEntfernenHandler } from "@/worker/ablaeufe/zielkonto";
/**
* Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Abgleich (#021) …
@@ -20,4 +21,6 @@ export const HANDLER: HandlerTabelle = {
PRIVATE_KEY_ABHOLEN: privateKeyAbholenHandler,
PRIVATE_KEY_LOESCHEN: privateKeyLoeschenHandler,
VERBINDUNGSTEST: verbindungstestHandler,
ZIELKONTO_ANLEGEN: zielkontoAnlegenHandler,
ZIELKONTO_ENTFERNEN: zielkontoEntfernenHandler,
};
+116
View File
@@ -0,0 +1,116 @@
import { execFileSync } from "node:child_process";
import { expect, test, type Page } from "@playwright/test";
import "dotenv/config";
/**
* Host-zu-Host-Assistent mit der Vorlage „Deployment-Zugang“ (#018–#020), gebauter Stand +
* Worker: Benutzer deploy auf B anlegen, Private Key auf A, Public Key auf B, Dry-Run,
* ausrollen, Verbindung testen.
*/
const ROOT_PASSWORT = process.env["SSHKV_TEST_ROOT_PASSWORT"] || "sshkv-test";
const PORT = {
a: (process.env["SSHKV_TEST_ZIEL_A"] ?? "127.0.0.1:2221").split(":")[1] ?? "2221",
b: (process.env["SSHKV_TEST_ZIEL_B"] ?? "127.0.0.1:2222").split(":")[1] ?? "2222",
};
test.setTimeout(240_000);
function aufZiel(ziel: "ziel-a" | "ziel-b", befehl: string): string {
return execFileSync(
"docker",
["compose", "-f", "docker-compose.test.yml", "exec", "-T", ziel, "sh", "-c", befehl],
{
encoding: "utf8",
},
);
}
async function hostEinrichten(page: Page, name: string, port: string) {
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(port);
await page.getByRole("button", { name: "Host anlegen" }).click();
await page.getByRole("button", { name: "Host-Key abrufen" }).click();
await page.getByRole("button", { name: "Fingerprint stimmt – vertrauen" }).click({ timeout: 30_000 });
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 });
}
test.beforeAll(() => {
const reset =
"userdel -r sshkv 2>/dev/null; userdel -r deploy 2>/dev/null; rm -f /etc/sudoers.d/sshkv*; rm -rf /root/.ssh; true";
aufZiel("ziel-a", reset);
aufZiel("ziel-b", reset);
});
test("Deployment-Zugang mit dem Assistenten", async ({ page }) => {
const kennung = Date.now().toString(36);
const hostA = `zz-e2e-a-${kennung}`;
const hostB = `zz-e2e-b-${kennung}`;
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 hostEinrichten(page, hostA, PORT.a);
await hostEinrichten(page, hostB, PORT.b);
// Frische Bestaetigung vorab (Benutzer anlegen verlangt sie); der Mock braucht auth_time.
await page.goto("/bestaetigen?ziel=%2Fassistent");
await page.getByRole("link", { name: /erneut anmelden/ }).click();
await page.locator('button[data-rolle="admin"]').click();
const claims = JSON.parse(await page.locator("#claims").inputValue()) as Record<string, unknown>;
await page.locator("#claims").fill(JSON.stringify({ ...claims, auth_time: Math.floor(Date.now() / 1000) }));
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
await expect(page.getByRole("heading", { name: "Host-zu-Host-Assistent" })).toBeVisible();
// 1. Vorlage & Zweck
await page.getByLabel("Vorlage").selectOption({ label: "Deployment-Zugang" });
await page.getByLabel("Zweck").fill(`zz-e2e Claude Code Deployment ${kennung}`);
await expect(page.getByLabel("Ablaufdatum (optional)")).not.toHaveValue("");
await page.getByRole("button", { name: "Weiter" }).click();
// 2. Quelle
await page.getByLabel("Host A").selectOption({ label: hostA });
await page.getByRole("button", { name: "Weiter" }).click();
// 3. Ziel
await page.getByLabel("Host B").selectOption({ label: hostB });
await expect(page.getByLabel("Benutzer auf B")).toHaveValue("deploy");
await expect(page.getByLabel("Einschränkungen in authorized_keys")).toHaveValue('from="127.0.0.1"');
await expect(page.getByLabel("Alias auf A")).toHaveValue(`deploy-${hostB}`);
// Im Docker-Netz kommt A nicht von 127.0.0.1 und erreicht B unter seinem Dienstnamen.
await page.getByLabel("Einschränkungen in authorized_keys").fill("no-agent-forwarding");
await page.getByLabel("HostName (aus Sicht von A)").fill("ziel-b");
await page.getByLabel("Port").fill("22");
await page.getByText("Fingerprint von B ist bestätigt").click();
await page.screenshot({ path: "docs/anleitung/bilder/assistent-ziel-desktop.png", fullPage: true });
await page.getByRole("button", { name: "Weiter" }).click();
// 4. Pruefen und vorbereiten
await expect(page.getByText(`„deploy“ auf ${hostB} anlegen`)).toBeVisible();
await page.getByRole("button", { name: "Vorbereiten" }).click();
await expect(page.getByRole("button", { name: "Dry-Run starten" })).toBeVisible({ timeout: 90_000 });
// 5. Dry-Run, ausrollen, testen
await page.getByRole("button", { name: "Dry-Run starten" }).click();
await expect(page.getByText(`deploy@${hostB}:/home/deploy/.ssh/authorized_keys`)).toBeVisible({
timeout: 30_000,
});
await page.getByRole("button", { name: /Änderung anwenden/ }).click();
await expect(page.getByText("Ausgerollt.")).toBeVisible({ timeout: 60_000 });
await page.getByRole("button", { name: "Verbindung von A nach B testen" }).click();
await expect(page.getByText("Erledigt.")).toBeVisible({ timeout: 30_000 });
await page.screenshot({ path: "docs/anleitung/bilder/assistent-fertig-desktop.png", fullPage: true });
expect(aufZiel("ziel-a", `ssh -o BatchMode=yes deploy-${hostB} id -un`).trim()).toBe("deploy");
for (const name of [hostA, hostB]) {
await page.goto("/hosts");
await page.getByRole("link", { name }).first().click();
await page.getByRole("button", { name: "Host archivieren" }).click();
await page.getByRole("dialog").getByRole("button", { name: "Archivieren", exact: true }).click();
await expect(page).toHaveURL(/\/hosts$/);
}
});
+8 -1
View File
@@ -21,7 +21,14 @@ export function masterKeyAnlegen(): () => void {
}
export async function vorgangLaufen(
art: "HOSTKEY_ABRUFEN" | "BOOTSTRAP" | "TROCKENLAUF" | "AUSROLLEN" | "VERBINDUNGSTEST",
art:
| "HOSTKEY_ABRUFEN"
| "BOOTSTRAP"
| "TROCKENLAUF"
| "AUSROLLEN"
| "VERBINDUNGSTEST"
| "ZIELKONTO_ANLEGEN"
| "ZIELKONTO_ENTFERNEN",
daten: {
hostId?: string;
eingabe?: object;
+26
View File
@@ -118,3 +118,29 @@ describe("Zielkonto", () => {
}
});
});
describe("Zielkonto als Vorgang", () => {
it("legt an, merkt sich die Herkunft und entfernt den Benutzer wieder", async () => {
const { prisma } = await import("@/lib/db");
const { vorgangLaufen } = await import("./hilfen/host");
const host = await eingerichteterHost(zielB, "ziel-b");
const anlegen = await vorgangLaufen("ZIELKONTO_ANLEGEN", {
hostId: host.id,
eingabe: { benutzer: "deploy", modus: "VOLL", befehle: [] },
});
expect(anlegen.fehler).toBeNull();
const konto = await prisma.zielkonto.findFirstOrThrow({ where: { hostId: host.id } });
expect(konto).toMatchObject({ benutzername: "deploy", vonSshkvAngelegt: true, sudoModus: "VOLL" });
expect(konto.sudoersInhalt).toContain("deploy ALL=(ALL:ALL) NOPASSWD: ALL");
const entfernen = await vorgangLaufen("ZIELKONTO_ENTFERNEN", {
hostId: host.id,
eingabe: { benutzer: "deploy" },
});
expect(entfernen.fehler).toBeNull();
expect((await befehlAlsRoot(zielB, "id deploy 2>&1")).stdout).toMatch(/no such user/);
expect(
(await prisma.zielkonto.findFirstOrThrow({ where: { hostId: host.id } })).entferntAm,
).not.toBeNull();
});
});