Merge branch 'phase/019-vorlagen'

This commit is contained in:
2026-10-12 10:54:31 +02:00
4 changed files with 418 additions and 0 deletions
+160
View File
@@ -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<Record<Platzhalter, string>>;
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,
};
}
+73
View File
@@ -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<FormularZustand> {
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<FormularZustand> {
try {
const { benutzer } = await rechtErforderlich("einstellungenVerwalten");
await vorlageArchivieren(textfeld(formular, "id") ?? "", akteurVon(benutzer));
} catch (fehler) {
return alsZustand(fehler);
}
revalidatePath("/vorlagen");
redirect("/vorlagen");
}
+74
View File
@@ -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<string> {
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<void> {
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<void> {
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 },
});
}
+111
View File
@@ -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"),
});
});
});