feat(assistent): Host-zu-Host-Assistent mit Vorlagen (#018, #020)

Ein Durchgang fuer den Kern-Anwendungsfall: Vorlage und Zweck, Host A,
Host B mit Zielbenutzer, Zusammenfassung, dann Schluessel erzeugen,
Benutzer anlegen, zuweisen, Dry-Run, ausrollen und Verbindung testen.
Die Vorlage Deployment-Zugang fuellt deploy, Ablaufdatum, from= und
Alias vor; bei root als Ziel warnt der Assistent ausdruecklich.

Die Ausfuehrung ist wiederaufnehmbar: wird mittendrin eine frische
Bestaetigung fuer das Zielkonto noetig, geschieht sie in einem neuen
Tab, und nichts wird doppelt angelegt. Zielkonten laufen als eigene
Vorgaenge (neue Migration) und merken sich, ob sshkv den Benutzer
angelegt hat - nur dann loescht ein Widerruf ihn spaeter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 11:19:28 +02:00
co-authored by Claude Opus 5.5
parent bdf12b05e7
commit b80f14c5d7
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();
});
});