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:
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';
|
||||||
@@ -110,6 +110,10 @@ enum VorgangsArt {
|
|||||||
SCHLUESSEL_IMPORTIEREN
|
SCHLUESSEL_IMPORTIEREN
|
||||||
/// Gespeicherten Private Key endgueltig verwerfen (#014).
|
/// Gespeicherten Private Key endgueltig verwerfen (#014).
|
||||||
PRIVATE_KEY_LOESCHEN
|
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 {
|
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."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -53,7 +53,7 @@ export default async function Uebersicht() {
|
|||||||
aktionen={
|
aktionen={
|
||||||
<>
|
<>
|
||||||
<Button icon={<RefreshCw className="size-4" />}>Jetzt abgleichen</Button>
|
<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
|
Host-zu-Host-Assistent
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>>;
|
||||||
@@ -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] },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 } };
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@ import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/
|
|||||||
import { privateKeyAbholenHandler, privateKeyLoeschenHandler } from "@/worker/ablaeufe/privatekey";
|
import { privateKeyAbholenHandler, privateKeyLoeschenHandler } from "@/worker/ablaeufe/privatekey";
|
||||||
import { schluesselErzeugenHandler, schluesselImportierenHandler } from "@/worker/ablaeufe/schluessel";
|
import { schluesselErzeugenHandler, schluesselImportierenHandler } from "@/worker/ablaeufe/schluessel";
|
||||||
import { verbindungstestHandler } from "@/worker/ablaeufe/verbindungstest";
|
import { verbindungstestHandler } from "@/worker/ablaeufe/verbindungstest";
|
||||||
|
import { zielkontoAnlegenHandler, zielkontoEntfernenHandler } from "@/worker/ablaeufe/zielkonto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Abgleich (#021) …
|
* Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Abgleich (#021) …
|
||||||
@@ -20,4 +21,6 @@ export const HANDLER: HandlerTabelle = {
|
|||||||
PRIVATE_KEY_ABHOLEN: privateKeyAbholenHandler,
|
PRIVATE_KEY_ABHOLEN: privateKeyAbholenHandler,
|
||||||
PRIVATE_KEY_LOESCHEN: privateKeyLoeschenHandler,
|
PRIVATE_KEY_LOESCHEN: privateKeyLoeschenHandler,
|
||||||
VERBINDUNGSTEST: verbindungstestHandler,
|
VERBINDUNGSTEST: verbindungstestHandler,
|
||||||
|
ZIELKONTO_ANLEGEN: zielkontoAnlegenHandler,
|
||||||
|
ZIELKONTO_ENTFERNEN: zielkontoEntfernenHandler,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -21,7 +21,14 @@ export function masterKeyAnlegen(): () => void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function vorgangLaufen(
|
export async function vorgangLaufen(
|
||||||
art: "HOSTKEY_ABRUFEN" | "BOOTSTRAP" | "TROCKENLAUF" | "AUSROLLEN" | "VERBINDUNGSTEST",
|
art:
|
||||||
|
| "HOSTKEY_ABRUFEN"
|
||||||
|
| "BOOTSTRAP"
|
||||||
|
| "TROCKENLAUF"
|
||||||
|
| "AUSROLLEN"
|
||||||
|
| "VERBINDUNGSTEST"
|
||||||
|
| "ZIELKONTO_ANLEGEN"
|
||||||
|
| "ZIELKONTO_ENTFERNEN",
|
||||||
daten: {
|
daten: {
|
||||||
hostId?: string;
|
hostId?: string;
|
||||||
eingabe?: object;
|
eingabe?: object;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user