"use client"; import { ArrowLeft, ArrowRight, ExternalLink, Play } from "lucide-react"; import { useState } from "react"; import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel"; import { Ankreuzfeld, Button, Feld, Formularfehler, Karte } from "@/components/ui"; import { Auswahlliste, Textbereich } from "@/components/ui/Feld"; import { Schrittleiste } from "@/components/ui/Schrittleiste"; import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf"; import { warten } from "@/components/vorgang/warten"; 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" ); } export function Assistent({ daten, bestaetigt }: { daten: AssistentDaten; bestaetigt: boolean }) { const [schritt, setSchritt] = useState(0); const [w, setW] = useState(LEER); const [ablauf, setAblauf] = useState({}); const setze = (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) => { 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 (
{schritt === 0 && (
vorlageWaehlen(e.target.value)} > {daten.vorlagen.map((v) => ( ))} {vorlage?.beschreibung &&

{vorlage.beschreibung}

} setze("zweck", e.target.value)} placeholder="Backup srv-backup01 → srv-pve01" required /> setze("ablaufAm", e.target.value)} hinweis="Projektbezogene Zugänge sollten ablaufen." />
)} {schritt === 1 && (
setze("quelleHostId", e.target.value)} > {daten.hosts.map((h) => ( ))}
setze("quelleBenutzer", e.target.value)} mono /> setze("pfad", e.target.value)} mono />
)} {schritt === 2 && (
zielVorbelegen(e.target.value)} > {daten.hosts .filter((h) => h.id !== w.quelleHostId) .map((h) => ( ))} setze("zielBenutzer", e.target.value)} mono /> {w.zielBenutzer === "root" && (

▲ 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.

)} setze("anlegen", e.target.checked)} /> {w.anlegen && (
setze("sudoModus", e.target.value as Werte["sudoModus"])} > {w.sudoModus === "BEFEHLE" && ( setze("sudoBefehle", e.target.value)} rows={3} /> )} {sudoFehler &&

{sudoFehler}

} {sudoVorschau && (
                    {sudoVorschau}
                  
)}
)} {quelle && !istIpAdresse(quelle.adresse) && ( setze("quellIp", e.target.value)} mono hinweis="Die Adresse, unter der B den Host A sieht." /> )} 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="", damit der Schlüssel nur von dort funktioniert.' } />
setze("alias", e.target.value)} mono /> setze("hostName", e.target.value)} mono /> setze("port", e.target.value)} mono />
setze("knownHosts", e.target.checked)} /> {w.knownHosts && ziel && (
{ziel.hostKeys.map((k) => (

{k.algorithmus} {k.fingerprint}

))} setze("fingerprintGeprueft", e.target.checked)} />
)}
)} {schritt === 3 && (
Schlüssel
ed25519 · {w.zweck} {w.ablaufAm && ( {" "} · läuft ab am {w.ablaufAm.split("-").reverse().join(".")} )}
Private Key
{w.quelleBenutzer}@{quelle?.name}:{w.pfad}
~/.ssh/config auf A
Host {w.alias} → {w.zielBenutzer}@{w.hostName}:{w.port}
Public Key
{w.optionen && `${w.optionen} · `} {w.zielBenutzer}@{ziel?.name}
{w.anlegen && ( <>
Zielbenutzer
„{w.zielBenutzer}“ auf {ziel?.name} anlegen,{" "} {w.sudoModus === "KEINER" ? ( "ohne sudo" ) : w.sudoModus === "VOLL" ? ( voller sudo-Zugriff ) : ( "sudo für bestimmte Befehle" )}
)}
{w.anlegen && !bestaetigt && !ablauf.bestaetigen && (

Das Anlegen des Benutzers verlangt eine frische Bestätigung Ihrer Anmeldung – sshkv fragt beim Ausführen danach.

)}
{ablauf.bestaetigen && ( Anmeldung in neuem Tab bestätigen, dann hier erneut ausführen )} {ablauf.laeuft && (

{ablauf.laeuft}

)}
)} {schritt === 4 && ablauf.schluesselId && ( <> {ablauf.privatVerwendungId && ( )}

Zum Schlüssel:{" "} {w.zweck}

)} {schritt < 3 && (
)} {schritt === 3 && !ablauf.schluesselId && (
)}
); }