Die Vorlagen-Logik aus #019 war bisher nur per Seed und Service erreichbar. Admins koennen eigene Vorlagen jetzt anlegen, eingebaute anpassen und eigene archivieren; jede Rolle sieht die Liste, weil Operatoren die Vorlagen im Assistenten wiedererkennen sollen. Die Vorschau nutzt im Browser dieselben reinen Funktionen wie der Server (platzhalterErsetzen, optionenPruefen, sudoersInhalt). So sieht der Admin vor dem Speichern, was in authorized_keys, ~/.ssh/config und sudoers landen wuerde; verbindlich prueft weiterhin der Server. Voller sudo-Zugang bekommt einen deutlichen Hinweis, weil er root gleichkommt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
378 lines
14 KiB
TypeScript
378 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import { TriangleAlert } from "lucide-react";
|
||
import { useState } from "react";
|
||
import { useFormularAktion } from "@/components/hosts/useFormularAktion";
|
||
import { Ankreuzfeld, Button, Feld, Feldgruppe, Formularfehler, Karte, Textbereich } from "@/components/ui";
|
||
import { MonoBlock } from "@/components/ui/MonoBlock";
|
||
import { optionenPruefen } from "@/lib/ssh/optionen";
|
||
import { sudoersInhalt } from "@/lib/ssh/zielkonto";
|
||
import { PLATZHALTER, platzhalterErsetzen, type PlatzhalterWerte } from "@/lib/vorlagen";
|
||
import { vorlageSpeichernAktion } from "@/server/actions/vorlagen";
|
||
|
||
type SudoModus = "KEINER" | "BEFEHLE" | "VOLL";
|
||
|
||
export type VorlageFormularVorgabe = {
|
||
id?: string;
|
||
name: string;
|
||
beschreibung: string;
|
||
schluesselTyp: "ED25519" | "RSA4096";
|
||
kategorie: "DIENST" | "PERSON";
|
||
standardLaufzeitTage: number | null;
|
||
zielbenutzer: string;
|
||
zielbenutzerAnlegen: boolean;
|
||
sudoModus: SudoModus;
|
||
sudoBefehle: string[];
|
||
authorizedKeysOptionen: string;
|
||
sshConfigAlias: string;
|
||
knownHostsEintragen: boolean;
|
||
privatNachAusrollenLoeschen: boolean;
|
||
};
|
||
|
||
export const LEERE_VORLAGE: VorlageFormularVorgabe = {
|
||
name: "",
|
||
beschreibung: "",
|
||
schluesselTyp: "ED25519",
|
||
kategorie: "DIENST",
|
||
standardLaufzeitTage: null,
|
||
zielbenutzer: "",
|
||
zielbenutzerAnlegen: false,
|
||
sudoModus: "KEINER",
|
||
sudoBefehle: [],
|
||
authorizedKeysOptionen: "",
|
||
sshConfigAlias: "",
|
||
knownHostsEintragen: true,
|
||
privatNachAusrollenLoeschen: false,
|
||
};
|
||
|
||
/** Beispielwerte der Vorschau – sichtbar andere als die Probewerte des Servers. */
|
||
const BEISPIEL: Required<PlatzhalterWerte> = {
|
||
quellHost: "srv-build01",
|
||
quellHostIp: "10.0.0.5",
|
||
zielHost: "srv-web01",
|
||
zielHostIp: "10.0.0.20",
|
||
};
|
||
|
||
type Vorschau = { wert: string | null; fehler?: string };
|
||
|
||
/**
|
||
* Die Vorschau ist nur Anzeige (#019): Fehler werden zum Hinweis, nicht zum Absturz – die
|
||
* verbindliche Pruefung macht der Server beim Speichern mit denselben Funktionen.
|
||
*/
|
||
function vorschau(berechnen: () => string | null): Vorschau {
|
||
try {
|
||
return { wert: berechnen() };
|
||
} catch (fehler) {
|
||
return { wert: null, fehler: fehler instanceof Error ? fehler.message : String(fehler) };
|
||
}
|
||
}
|
||
|
||
function befehlsZeilen(text: string): string[] {
|
||
return text
|
||
.split("\n")
|
||
.map((b) => b.trim())
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function Vorschauzeile({ titel, ergebnis, leer }: { titel: string; ergebnis: Vorschau; leer: string }) {
|
||
return (
|
||
<div className="flex min-w-0 flex-col gap-1">
|
||
<dt className="text-muted text-[13px] font-medium">{titel}</dt>
|
||
<dd className="min-w-0">
|
||
{ergebnis.fehler ? (
|
||
<span className="text-danger text-[13.5px]">{ergebnis.fehler}</span>
|
||
) : ergebnis.wert ? (
|
||
<code className="text-text font-mono text-[13px] break-all">{ergebnis.wert}</code>
|
||
) : (
|
||
<span className="text-muted text-[13.5px]">{leer}</span>
|
||
)}
|
||
</dd>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Formular fuer neue und bestehende Vorlagen (#019). Regeln prueft der Server. */
|
||
export function VorlagenFormular({ vorgabe }: { vorgabe: VorlageFormularVorgabe }) {
|
||
const bearbeiten = Boolean(vorgabe.id);
|
||
const { zustand, absenden, laeuft } = useFormularAktion(vorlageSpeichernAktion);
|
||
const f = zustand.feldfehler ?? {};
|
||
|
||
// Kontrolliert nur, was die Vorschau braucht; der Rest bleibt unkontrolliert.
|
||
const [zielbenutzer, setZielbenutzer] = useState(vorgabe.zielbenutzer);
|
||
const [sudoModus, setSudoModus] = useState<SudoModus>(vorgabe.sudoModus);
|
||
const [befehle, setBefehle] = useState(vorgabe.sudoBefehle.join("\n"));
|
||
const [optionen, setOptionen] = useState(vorgabe.authorizedKeysOptionen);
|
||
const [alias, setAlias] = useState(vorgabe.sshConfigAlias);
|
||
|
||
const optionenVorschau = vorschau(() =>
|
||
optionen.trim() ? optionenPruefen(platzhalterErsetzen(optionen.trim(), BEISPIEL)) || null : null,
|
||
);
|
||
const aliasVorschau = vorschau(() => (alias.trim() ? platzhalterErsetzen(alias.trim(), BEISPIEL) : null));
|
||
const sudoersVorschau = vorschau(() =>
|
||
sudoModus === "KEINER" ? null : sudoersInhalt(zielbenutzer.trim(), sudoModus, befehlsZeilen(befehle)),
|
||
);
|
||
|
||
return (
|
||
<form onSubmit={absenden} noValidate className="flex flex-col gap-6">
|
||
{vorgabe.id && <input type="hidden" name="id" value={vorgabe.id} />}
|
||
<Formularfehler text={zustand.fehler} />
|
||
|
||
<Karte titel="Allgemein">
|
||
<div className="flex flex-col gap-4">
|
||
<Feld
|
||
beschriftung="Name"
|
||
name="name"
|
||
defaultValue={vorgabe.name}
|
||
required
|
||
maxLength={80}
|
||
autoComplete="off"
|
||
placeholder="z. B. Monitoring-Zugang"
|
||
fehler={f["name"]}
|
||
/>
|
||
<Textbereich
|
||
beschriftung="Beschreibung (optional)"
|
||
name="beschreibung"
|
||
defaultValue={vorgabe.beschreibung}
|
||
maxLength={500}
|
||
fehler={f["beschreibung"]}
|
||
/>
|
||
</div>
|
||
</Karte>
|
||
|
||
<Karte titel="Schlüssel">
|
||
<div className="flex flex-col gap-5">
|
||
<Feldgruppe legende="Typ" fehler={f["schluesselTyp"]}>
|
||
<div className="grid gap-x-4 sm:grid-cols-2">
|
||
<Ankreuzfeld
|
||
type="radio"
|
||
name="schluesselTyp"
|
||
value="ED25519"
|
||
defaultChecked={vorgabe.schluesselTyp === "ED25519"}
|
||
beschriftung={<span className="font-mono">ed25519</span>}
|
||
erklaerung="Empfohlen – kurz, schnell, sicher."
|
||
/>
|
||
<Ankreuzfeld
|
||
type="radio"
|
||
name="schluesselTyp"
|
||
value="RSA4096"
|
||
defaultChecked={vorgabe.schluesselTyp === "RSA4096"}
|
||
beschriftung={<span className="font-mono">rsa-4096</span>}
|
||
erklaerung="Nur für alte Gegenstellen ohne ed25519."
|
||
/>
|
||
</div>
|
||
</Feldgruppe>
|
||
<Feldgruppe legende="Kategorie" fehler={f["kategorie"]}>
|
||
<div className="grid gap-x-4 sm:grid-cols-2">
|
||
<Ankreuzfeld
|
||
type="radio"
|
||
name="kategorie"
|
||
value="DIENST"
|
||
defaultChecked={vorgabe.kategorie === "DIENST"}
|
||
beschriftung="Dienst"
|
||
erklaerung="Host-zu-Host oder Automatisierung; sshkv verwahrt den Private Key."
|
||
/>
|
||
<Ankreuzfeld
|
||
type="radio"
|
||
name="kategorie"
|
||
value="PERSON"
|
||
defaultChecked={vorgabe.kategorie === "PERSON"}
|
||
beschriftung="Person"
|
||
erklaerung="Der Private Key bleibt bei der Person; kein Benutzer anlegen, kein sudo."
|
||
/>
|
||
</div>
|
||
</Feldgruppe>
|
||
<Feld
|
||
beschriftung="Standard-Laufzeit in Tagen (optional)"
|
||
name="standardLaufzeitTage"
|
||
type="number"
|
||
inputMode="numeric"
|
||
min={1}
|
||
max={3650}
|
||
defaultValue={vorgabe.standardLaufzeitTage ?? ""}
|
||
hinweis="Leer lassen für Schlüssel ohne Ablaufdatum."
|
||
fehler={f["standardLaufzeitTage"]}
|
||
className="sm:max-w-xs"
|
||
/>
|
||
</div>
|
||
</Karte>
|
||
|
||
<Karte titel="Zielkonto" untertitel="Unter welchem Benutzer der Schlüssel auf dem Zielhost landet">
|
||
<div className="flex flex-col gap-5">
|
||
<Feld
|
||
beschriftung="Zielbenutzer"
|
||
name="zielbenutzer"
|
||
value={zielbenutzer}
|
||
onChange={(e) => setZielbenutzer(e.target.value)}
|
||
mono
|
||
required
|
||
maxLength={32}
|
||
autoComplete="off"
|
||
spellCheck={false}
|
||
placeholder="deploy"
|
||
hinweis="Kleinbuchstaben, Ziffern, _ und -, höchstens 32 Zeichen."
|
||
fehler={f["zielbenutzer"]}
|
||
className="sm:max-w-xs"
|
||
/>
|
||
<Ankreuzfeld
|
||
name="zielbenutzerAnlegen"
|
||
value="ja"
|
||
defaultChecked={vorgabe.zielbenutzerAnlegen}
|
||
beschriftung="Benutzer anlegen, falls er fehlt"
|
||
erklaerung="sshkv legt ihn beim Ausrollen an und entfernt ihn beim Widerruf wieder."
|
||
/>
|
||
<Feldgruppe
|
||
legende="sudo-Rechte"
|
||
hinweis="Nur für Benutzer, die sshkv selbst anlegt."
|
||
fehler={f["sudoModus"]}
|
||
>
|
||
<div className="grid gap-x-4 sm:grid-cols-3">
|
||
{(
|
||
[
|
||
["KEINER", "keine"],
|
||
["BEFEHLE", "bestimmte Befehle"],
|
||
["VOLL", "voll"],
|
||
] as const
|
||
).map(([wert, text]) => (
|
||
<Ankreuzfeld
|
||
key={wert}
|
||
type="radio"
|
||
name="sudoModus"
|
||
value={wert}
|
||
checked={sudoModus === wert}
|
||
onChange={() => setSudoModus(wert)}
|
||
beschriftung={text}
|
||
/>
|
||
))}
|
||
</div>
|
||
</Feldgruppe>
|
||
{/* Ausgeblendet statt entfernt, damit die Befehle beim Hin- und Herschalten bleiben. */}
|
||
<Textbereich
|
||
beschriftung="Erlaubte Befehle"
|
||
name="sudoBefehle"
|
||
value={befehle}
|
||
onChange={(e) => setBefehle(e.target.value)}
|
||
rows={4}
|
||
spellCheck={false}
|
||
placeholder={"/usr/bin/systemctl restart nginx\n/usr/bin/systemctl reload nginx"}
|
||
hinweis="Ein Befehl je Zeile, mit absolutem Pfad. Argumente sind erlaubt, Platzhalter nicht."
|
||
fehler={f["sudoBefehle"]}
|
||
className={sudoModus === "BEFEHLE" ? "[&_textarea]:font-mono [&_textarea]:text-[13px]" : "hidden"}
|
||
/>
|
||
{sudoModus === "VOLL" && (
|
||
<p
|
||
role="note"
|
||
className="border-danger-border bg-danger-soft text-danger rounded-box flex items-start gap-2.5 border px-3 py-2.5 text-[14px]"
|
||
>
|
||
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||
<span>
|
||
Voller sudo-Zugang ohne Passwort: Wer diesen Schlüssel hat, ist auf dem Zielhost faktisch{" "}
|
||
<strong className="font-semibold">root</strong>. Nur wählen, wenn bestimmte Befehle nicht
|
||
reichen.
|
||
</span>
|
||
</p>
|
||
)}
|
||
</div>
|
||
</Karte>
|
||
|
||
<Karte titel="authorized_keys & ssh">
|
||
<div className="flex flex-col gap-5">
|
||
<Feld
|
||
beschriftung="authorized_keys-Optionen (optional)"
|
||
name="authorizedKeysOptionen"
|
||
value={optionen}
|
||
onChange={(e) => setOptionen(e.target.value)}
|
||
mono
|
||
autoComplete="off"
|
||
spellCheck={false}
|
||
placeholder={'from="{{quellHostIp}}",no-port-forwarding'}
|
||
fehler={f["authorizedKeysOptionen"]}
|
||
/>
|
||
<Feld
|
||
beschriftung="Alias-Muster für ~/.ssh/config (optional)"
|
||
name="sshConfigAlias"
|
||
value={alias}
|
||
onChange={(e) => setAlias(e.target.value)}
|
||
mono
|
||
autoComplete="off"
|
||
spellCheck={false}
|
||
placeholder="deploy-{{zielHost}}"
|
||
fehler={f["sshConfigAlias"]}
|
||
/>
|
||
<div className="border-border bg-sidebar rounded-box text-muted border px-4 py-3 text-[13px]">
|
||
<p>
|
||
Platzhalter:{" "}
|
||
{PLATZHALTER.map((p, i) => (
|
||
<span key={p}>
|
||
{i > 0 && ", "}
|
||
<code className="text-text font-mono whitespace-nowrap">{`{{${p}}}`}</code>
|
||
</span>
|
||
))}
|
||
. Beim Ausrollen setzt sshkv Name und IP von Quell- und Zielhost ein.
|
||
</p>
|
||
<p className="mt-1.5">
|
||
Beispiel: <code className="text-text font-mono break-all">{'from="{{quellHostIp}}"'}</code> wird
|
||
zu <code className="text-text font-mono break-all">{`from="${BEISPIEL.quellHostIp}"`}</code>.
|
||
</p>
|
||
</div>
|
||
<div className="flex flex-col">
|
||
<Ankreuzfeld
|
||
name="knownHostsEintragen"
|
||
value="ja"
|
||
defaultChecked={vorgabe.knownHostsEintragen}
|
||
beschriftung="Host-Key des Ziels in known_hosts der Quelle eintragen"
|
||
/>
|
||
<Ankreuzfeld
|
||
name="privatNachAusrollenLoeschen"
|
||
value="ja"
|
||
defaultChecked={vorgabe.privatNachAusrollenLoeschen}
|
||
beschriftung="Private Key nach dem Ausrollen in sshkv löschen"
|
||
erklaerung="Dann liegt er nur noch auf dem Quellhost und lässt sich nicht erneut ausrollen."
|
||
/>
|
||
</div>
|
||
</div>
|
||
</Karte>
|
||
|
||
<Karte
|
||
titel="Vorschau"
|
||
untertitel={
|
||
<>
|
||
Mit Beispielwerten: Quelle <span className="font-mono">{BEISPIEL.quellHost}</span> (
|
||
<span className="font-mono">{BEISPIEL.quellHostIp}</span>), Ziel{" "}
|
||
<span className="font-mono">{BEISPIEL.zielHost}</span> (
|
||
<span className="font-mono">{BEISPIEL.zielHostIp}</span>)
|
||
</>
|
||
}
|
||
>
|
||
<div aria-live="polite" className="flex flex-col gap-4">
|
||
<dl className="grid gap-4 sm:grid-cols-2">
|
||
<Vorschauzeile titel="authorized_keys-Optionen" ergebnis={optionenVorschau} leer="keine" />
|
||
<Vorschauzeile titel="Alias" ergebnis={aliasVorschau} leer="kein Eintrag in ~/.ssh/config" />
|
||
</dl>
|
||
{sudoModus !== "KEINER" &&
|
||
(sudoersVorschau.fehler ? (
|
||
<p className="text-danger text-[13.5px]">sudoers: {sudoersVorschau.fehler}</p>
|
||
) : (
|
||
sudoersVorschau.wert && (
|
||
<div className="flex min-w-0 flex-col gap-1.5">
|
||
<p className="text-muted text-[13px] font-medium">
|
||
<span className="font-mono">/etc/sudoers.d/sshkv-{zielbenutzer.trim()}</span>
|
||
</p>
|
||
{/* Ohne Umbruch: sudoers ist zeilenbasiert, ein optischer Umbruch taeuschte eine
|
||
zweite Regel vor (#019). Der Block scrollt dann fuer sich. */}
|
||
<MonoBlock wert={sudoersVorschau.wert} beschriftung="sudoers-Inhalt" />
|
||
</div>
|
||
)
|
||
))}
|
||
</div>
|
||
</Karte>
|
||
|
||
<div className="flex flex-wrap gap-2.5">
|
||
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||
{laeuft ? "Speichere …" : bearbeiten ? "Änderungen speichern" : "Vorlage anlegen"}
|
||
</Button>
|
||
<Button href="/vorlagen">Abbrechen</Button>
|
||
</div>
|
||
</form>
|
||
);
|
||
}
|