diff --git a/src/lib/vorlagen.ts b/src/lib/vorlagen.ts new file mode 100644 index 0000000..128b11e --- /dev/null +++ b/src/lib/vorlagen.ts @@ -0,0 +1,160 @@ +import { BENUTZERNAME_MUSTER } from "@/lib/shell"; +import { OptionenFehler, optionenPruefen } from "@/lib/ssh/optionen"; +import { befehlPruefen, ZielkontoFehler } from "@/lib/ssh/zielkonto"; + +/** + * Vorlagen (#019): Platzhalter ersetzen und Vorlagen pruefen – rein, damit Assistent, + * Formular und Tests dieselben Regeln haben. + * + * Platzhalter: {{quellHost}}, {{quellHostIp}}, {{zielHost}}, {{zielHostIp}}. Ein unbekannter + * Platzhalter ist ein Fehler, kein leerer Text – sonst stuende `from=""` in authorized_keys. + */ +export const PLATZHALTER = ["quellHost", "quellHostIp", "zielHost", "zielHostIp"] as const; +export type Platzhalter = (typeof PLATZHALTER)[number]; +export type PlatzhalterWerte = Partial>; + +export class VorlagenFehler extends Error { + constructor( + nachricht: string, + readonly feld?: string, + ) { + super(nachricht); + this.name = "VorlagenFehler"; + } +} + +const MUSTER = /\{\{\s*([A-Za-z]+)\s*\}\}/g; + +export function platzhalterIn(text: string): string[] { + return [...text.matchAll(MUSTER)].map((treffer) => treffer[1]!); +} + +export function platzhalterErsetzen(text: string, werte: PlatzhalterWerte): string { + return text.replace(MUSTER, (_, name: string) => { + if (!(PLATZHALTER as readonly string[]).includes(name)) { + throw new VorlagenFehler(`Unbekannter Platzhalter {{${name}}}.`); + } + const wert = werte[name as Platzhalter]; + if (!wert) throw new VorlagenFehler(`Für {{${name}}} fehlt ein Wert.`); + if (/["\\\s,]/.test(wert)) + throw new VorlagenFehler(`Der Wert für {{${name}}} enthält unzulässige Zeichen.`); + return wert; + }); +} + +/** Eine IPv4/IPv6-Adresse oder ein Netz in CIDR-Schreibweise, wie sie in from="…" stehen darf. */ +export function istIpAdresse(wert: string): boolean { + return ( + /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/.test(wert) || + (/^[0-9a-f:]+(\/\d{1,3})?$/i.test(wert) && wert.includes(":")) + ); +} + +export type VorlageEingabe = { + readonly name: string; + readonly beschreibung?: string | null; + readonly schluesselTyp: "ED25519" | "RSA4096"; + readonly kategorie: "PERSON" | "DIENST"; + readonly standardLaufzeitTage?: number | null; + readonly zielbenutzer: string; + readonly zielbenutzerAnlegen: boolean; + readonly sudoModus: "KEINER" | "BEFEHLE" | "VOLL"; + readonly sudoBefehle: readonly string[]; + readonly authorizedKeysOptionen?: string | null; + readonly sshConfigAlias?: string | null; + readonly knownHostsEintragen: boolean; + readonly privatNachAusrollenLoeschen: boolean; +}; + +/** Beispielwerte, mit denen eine Vorlage beim Speichern probeweise aufgeloest wird. */ +const PROBE: PlatzhalterWerte = { + quellHost: "srv-quelle", + quellHostIp: "10.0.0.1", + zielHost: "srv-ziel", + zielHostIp: "10.0.0.2", +}; + +/** Prueft eine Vorlage so, wie sie spaeter benutzt wird: mit aufgeloesten Platzhaltern. */ +export function vorlagePruefen(eingabe: VorlageEingabe): VorlageEingabe { + if (!eingabe.name.trim() || eingabe.name.length > 80) + throw new VorlagenFehler("Bitte einen Namen angeben.", "name"); + if (!BENUTZERNAME_MUSTER.test(eingabe.zielbenutzer)) { + throw new VorlagenFehler("Ungültiger Zielbenutzer.", "zielbenutzer"); + } + if ( + eingabe.standardLaufzeitTage != null && + (eingabe.standardLaufzeitTage < 1 || eingabe.standardLaufzeitTage > 3650) + ) { + throw new VorlagenFehler("Laufzeit zwischen 1 und 3650 Tagen.", "standardLaufzeitTage"); + } + if (eingabe.kategorie === "PERSON" && (eingabe.zielbenutzerAnlegen || eingabe.sudoModus !== "KEINER")) { + throw new VorlagenFehler( + "Personen-Vorlagen legen keine Benutzer an und vergeben kein sudo.", + "kategorie", + ); + } + if (!eingabe.zielbenutzerAnlegen && eingabe.sudoModus !== "KEINER") { + throw new VorlagenFehler( + "sudo-Rechte vergibt sshkv nur für Benutzer, die es selbst anlegt.", + "sudoModus", + ); + } + try { + if (eingabe.sudoModus === "BEFEHLE") { + if (eingabe.sudoBefehle.length === 0) + throw new VorlagenFehler("Bitte mindestens einen Befehl angeben.", "sudoBefehle"); + eingabe.sudoBefehle.forEach(befehlPruefen); + } + if (eingabe.authorizedKeysOptionen) + optionenPruefen(platzhalterErsetzen(eingabe.authorizedKeysOptionen, PROBE)); + } catch (fehler) { + if (fehler instanceof ZielkontoFehler) throw new VorlagenFehler(fehler.message, "sudoBefehle"); + if (fehler instanceof OptionenFehler || fehler instanceof VorlagenFehler) { + throw new VorlagenFehler( + fehler.message, + fehler instanceof VorlagenFehler && fehler.feld ? fehler.feld : "authorizedKeysOptionen", + ); + } + throw fehler; + } + if (eingabe.sshConfigAlias) { + let alias: string; + try { + alias = platzhalterErsetzen(eingabe.sshConfigAlias, PROBE); + } catch (fehler) { + throw new VorlagenFehler((fehler as Error).message, "sshConfigAlias"); + } + if (!/^[A-Za-z0-9._-]{1,64}$/.test(alias)) + throw new VorlagenFehler("Ungültiger Alias.", "sshConfigAlias"); + } + return eingabe; +} + +export type AufgeloesteVorlage = { + readonly zielbenutzer: string; + readonly authorizedKeysOptionen: string | null; + readonly sshConfigAlias: string | null; + readonly ablaufAm: Date | null; +}; + +/** Loest eine Vorlage fuer einen konkreten Assistenten-Lauf auf (#020). */ +export function vorlageAufloesen( + vorlage: Pick< + VorlageEingabe, + "zielbenutzer" | "authorizedKeysOptionen" | "sshConfigAlias" | "standardLaufzeitTage" + >, + werte: PlatzhalterWerte, + jetzt = new Date(), +): AufgeloesteVorlage { + const optionen = vorlage.authorizedKeysOptionen + ? optionenPruefen(platzhalterErsetzen(vorlage.authorizedKeysOptionen, werte)) || null + : null; + return { + zielbenutzer: vorlage.zielbenutzer, + authorizedKeysOptionen: optionen, + sshConfigAlias: vorlage.sshConfigAlias ? platzhalterErsetzen(vorlage.sshConfigAlias, werte) : null, + ablaufAm: vorlage.standardLaufzeitTage + ? new Date(jetzt.getTime() + vorlage.standardLaufzeitTage * 24 * 60 * 60 * 1000) + : null, + }; +} diff --git a/src/server/actions/vorlagen.ts b/src/server/actions/vorlagen.ts new file mode 100644 index 0000000..6c35119 --- /dev/null +++ b/src/server/actions/vorlagen.ts @@ -0,0 +1,73 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { VorlagenFehler, type VorlageEingabe } from "@/lib/vorlagen"; +import { akteurVon, fehlerZustand, textfeld, type FormularZustand } from "@/server/actions/formular"; +import { rechtErforderlich } from "@/server/auth/sitzungspflicht"; +import { vorlageAnlegen, vorlageArchivieren, vorlageBearbeiten } from "@/server/services/vorlagen"; + +/** Vorlagen-Aktionen (#019): nur Admins (Recht einstellungenVerwalten). */ + +function felder(formular: FormData): VorlageEingabe { + const tage = textfeld(formular, "standardLaufzeitTage"); + return { + name: textfeld(formular, "name") ?? "", + beschreibung: textfeld(formular, "beschreibung") ?? null, + schluesselTyp: textfeld(formular, "schluesselTyp") === "RSA4096" ? "RSA4096" : "ED25519", + kategorie: textfeld(formular, "kategorie") === "PERSON" ? "PERSON" : "DIENST", + standardLaufzeitTage: tage ? Number(tage) : null, + zielbenutzer: (textfeld(formular, "zielbenutzer") ?? "").trim(), + zielbenutzerAnlegen: formular.get("zielbenutzerAnlegen") === "ja", + sudoModus: + (["KEINER", "BEFEHLE", "VOLL"] as const).find((m) => m === textfeld(formular, "sudoModus")) ?? "KEINER", + sudoBefehle: (textfeld(formular, "sudoBefehle") ?? "") + .split("\n") + .map((b) => b.trim()) + .filter(Boolean), + authorizedKeysOptionen: textfeld(formular, "authorizedKeysOptionen") ?? null, + sshConfigAlias: textfeld(formular, "sshConfigAlias") ?? null, + knownHostsEintragen: formular.get("knownHostsEintragen") === "ja", + privatNachAusrollenLoeschen: formular.get("privatNachAusrollenLoeschen") === "ja", + }; +} + +function alsZustand(fehler: unknown): FormularZustand { + if (fehler instanceof VorlagenFehler) { + return { + fehler: fehler.message, + ...(fehler.feld ? { feldfehler: { [fehler.feld]: fehler.message } } : {}), + }; + } + return fehlerZustand(fehler); +} + +export async function vorlageSpeichernAktion( + _vorher: FormularZustand, + formular: FormData, +): Promise { + const id = textfeld(formular, "id"); + try { + const { benutzer } = await rechtErforderlich("einstellungenVerwalten"); + if (id) await vorlageBearbeiten(id, felder(formular), akteurVon(benutzer)); + else await vorlageAnlegen(felder(formular), akteurVon(benutzer)); + } catch (fehler) { + return alsZustand(fehler); + } + revalidatePath("/vorlagen"); + redirect("/vorlagen"); +} + +export async function vorlageArchivierenAktion( + _vorher: FormularZustand, + formular: FormData, +): Promise { + try { + const { benutzer } = await rechtErforderlich("einstellungenVerwalten"); + await vorlageArchivieren(textfeld(formular, "id") ?? "", akteurVon(benutzer)); + } catch (fehler) { + return alsZustand(fehler); + } + revalidatePath("/vorlagen"); + redirect("/vorlagen"); +} diff --git a/src/server/services/vorlagen.ts b/src/server/services/vorlagen.ts new file mode 100644 index 0000000..fc01ad9 --- /dev/null +++ b/src/server/services/vorlagen.ts @@ -0,0 +1,74 @@ +import { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { vorlagePruefen, VorlagenFehler, type VorlageEingabe } from "@/lib/vorlagen"; +import { protokollieren } from "@/server/services/protokoll"; + +/** Vorlagen anlegen, bearbeiten, archivieren (#019). Eingebaute lassen sich nur bearbeiten. */ +type Akteur = { readonly id: string; readonly name: string }; + +function daten(eingabe: VorlageEingabe) { + const v = vorlagePruefen(eingabe); + return { + name: v.name.trim(), + beschreibung: v.beschreibung?.trim() || null, + schluesselTyp: v.schluesselTyp, + kategorie: v.kategorie, + standardLaufzeitTage: v.standardLaufzeitTage ?? null, + zielbenutzer: v.zielbenutzer, + zielbenutzerAnlegen: v.zielbenutzerAnlegen, + sudoModus: v.sudoModus, + sudoBefehle: v.sudoModus === "BEFEHLE" ? v.sudoBefehle.map((b) => b.trim().replace(/\s+/g, " ")) : [], + authorizedKeysOptionen: v.authorizedKeysOptionen?.trim() || null, + sshConfigAlias: v.sshConfigAlias?.trim() || null, + knownHostsEintragen: v.knownHostsEintragen, + privatNachAusrollenLoeschen: v.privatNachAusrollenLoeschen, + }; +} + +function eindeutig(fehler: unknown): never { + if (fehler instanceof Prisma.PrismaClientKnownRequestError && fehler.code === "P2002") { + throw new VorlagenFehler("Eine Vorlage mit diesem Namen gibt es bereits.", "name"); + } + throw fehler; +} + +export async function vorlageAnlegen(eingabe: VorlageEingabe, akteur: Akteur): Promise { + const werte = daten(eingabe); + const vorlage = await prisma.vorlage.create({ data: werte }).catch(eindeutig); + await protokollieren({ + akteur, + aktion: "vorlage.angelegt", + ergebnis: "ERFOLG", + details: { name: werte.name }, + }); + return vorlage.id; +} + +export async function vorlageBearbeiten(id: string, eingabe: VorlageEingabe, akteur: Akteur): Promise { + const werte = daten(eingabe); + const alt = await prisma.vorlage.findUnique({ where: { id } }); + if (!alt || alt.archiviertAm) throw new VorlagenFehler("Diese Vorlage gibt es nicht (mehr)."); + await prisma.vorlage.update({ where: { id }, data: werte }).catch(eindeutig); + const geaendert = Object.entries(werte) + .filter(([feld, wert]) => JSON.stringify(alt[feld as keyof typeof alt]) !== JSON.stringify(wert)) + .map(([feld]) => feld); + await protokollieren({ + akteur, + aktion: "vorlage.geaendert", + ergebnis: "ERFOLG", + details: { name: werte.name, geaendert }, + }); +} + +export async function vorlageArchivieren(id: string, akteur: Akteur): Promise { + const vorlage = await prisma.vorlage.findUnique({ where: { id } }); + if (!vorlage || vorlage.archiviertAm) throw new VorlagenFehler("Diese Vorlage gibt es nicht (mehr)."); + if (vorlage.eingebaut) throw new VorlagenFehler("Eingebaute Vorlagen lassen sich nicht archivieren."); + await prisma.vorlage.update({ where: { id }, data: { archiviertAm: new Date() } }); + await protokollieren({ + akteur, + aktion: "vorlage.archiviert", + ergebnis: "ERFOLG", + details: { name: vorlage.name }, + }); +} diff --git a/tests/vorlagen.test.ts b/tests/vorlagen.test.ts new file mode 100644 index 0000000..354e85c --- /dev/null +++ b/tests/vorlagen.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { EINGEBAUTE_VORLAGEN } from "../prisma/vorlagen"; +import { + VorlagenFehler, + istIpAdresse, + platzhalterErsetzen, + vorlageAufloesen, + vorlagePruefen, + type VorlageEingabe, +} from "@/lib/vorlagen"; + +/** Vorlagen und Platzhalter (#019). */ + +const BASIS: VorlageEingabe = { + name: "Test", + schluesselTyp: "ED25519", + kategorie: "DIENST", + zielbenutzer: "deploy", + zielbenutzerAnlegen: true, + sudoModus: "KEINER", + sudoBefehle: [], + knownHostsEintragen: true, + privatNachAusrollenLoeschen: false, +}; + +describe("Platzhalter", () => { + it("ersetzt bekannte Platzhalter", () => { + expect(platzhalterErsetzen('from="{{quellHostIp}}"', { quellHostIp: "10.0.0.5" })).toBe( + 'from="10.0.0.5"', + ); + expect(platzhalterErsetzen("deploy-{{ zielHost }}", { zielHost: "srv-app01" })).toBe("deploy-srv-app01"); + }); + + it("verweigert unbekannte, fehlende und gefaehrliche Werte", () => { + expect(() => platzhalterErsetzen("{{irgendwas}}", {})).toThrow(VorlagenFehler); + expect(() => platzhalterErsetzen("{{quellHostIp}}", {})).toThrow(/fehlt ein Wert/); + expect(() => + platzhalterErsetzen('from="{{quellHostIp}}"', { quellHostIp: '1.2.3.4",command="id' }), + ).toThrow(VorlagenFehler); + }); + + it("erkennt IP-Adressen", () => { + expect(istIpAdresse("10.0.0.5")).toBe(true); + expect(istIpAdresse("10.0.0.0/24")).toBe(true); + expect(istIpAdresse("fe80::1")).toBe(true); + expect(istIpAdresse("srv-app01")).toBe(false); + }); +}); + +describe("Vorlage pruefen", () => { + it("nimmt alle eingebauten Vorlagen an", () => { + for (const vorlage of EINGEBAUTE_VORLAGEN) { + expect(() => + vorlagePruefen({ + ...BASIS, + ...vorlage, + beschreibung: vorlage.beschreibung ?? null, + kategorie: vorlage.kategorie as "PERSON" | "DIENST", + schluesselTyp: (vorlage.schluesselTyp ?? "ED25519") as "ED25519", + sudoBefehle: [], + zielbenutzerAnlegen: vorlage.zielbenutzerAnlegen ?? false, + sudoModus: (vorlage.sudoModus ?? "KEINER") as "KEINER", + knownHostsEintragen: vorlage.knownHostsEintragen ?? true, + privatNachAusrollenLoeschen: false, + }), + ).not.toThrow(); + } + }); + + it.each([ + [{ zielbenutzer: "Root" }, "zielbenutzer"], + [{ authorizedKeysOptionen: 'from="{{unbekannt}}"' }, "authorizedKeysOptionen"], + [{ authorizedKeysOptionen: "restriktiv" }, "authorizedKeysOptionen"], + [{ sudoModus: "BEFEHLE" as const, sudoBefehle: [] }, "sudoBefehle"], + [{ sudoModus: "BEFEHLE" as const, sudoBefehle: ["systemctl restart x"] }, "sudoBefehle"], + [{ zielbenutzerAnlegen: false, sudoModus: "VOLL" as const }, "sudoModus"], + [{ sshConfigAlias: "a b {{zielHost}}" }, "sshConfigAlias"], + [{ kategorie: "PERSON" as const }, "kategorie"], + ])("weist ab: %o", (fehler, feld) => { + try { + vorlagePruefen({ ...BASIS, ...fehler }); + throw new Error("kein Fehler"); + } catch (fehlerObjekt) { + expect(fehlerObjekt).toBeInstanceOf(VorlagenFehler); + expect((fehlerObjekt as VorlagenFehler).feld).toBe(feld); + } + }); +}); + +describe("Vorlage aufloesen", () => { + it("liefert Optionen, Alias und Ablaufdatum", () => { + const jetzt = new Date("2026-10-12T10:00:00Z"); + expect( + vorlageAufloesen( + { + zielbenutzer: "deploy", + authorizedKeysOptionen: 'from="{{quellHostIp}}"', + sshConfigAlias: "deploy-{{zielHost}}", + standardLaufzeitTage: 90, + }, + { quellHostIp: "10.0.0.5", zielHost: "srv-app01" }, + jetzt, + ), + ).toEqual({ + zielbenutzer: "deploy", + authorizedKeysOptionen: 'from="10.0.0.5"', + sshConfigAlias: "deploy-srv-app01", + ablaufAm: new Date("2027-01-10T10:00:00Z"), + }); + }); +});