Merge branch 'phase/018-020-assistent'
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