import { MAX_TAGS, TAG } from "@/lib/hosts"; import { tageBis } from "@/lib/format"; import { z } from "@/lib/validierung"; /** * Eingaben und Anzeigeregeln fuer Schluessel (#012). * * Framework-frei: Web-Service, Server Actions, Worker-Ablaeufe und Tests teilen dieselben * Regeln. Der Worker prueft die Metadaten aus `Vorgang.eingabe` noch einmal, weil zwischen * Anfrage und Ausfuehrung Zeit vergeht und die Eingabe in der Datenbank liegt. */ /** Wie der DB-CHECK `schluessel_slug_gueltig`. */ export const SLUG = /^[a-z0-9-]{1,64}$/; export const NAME_MAX = 120; export const KOMMENTAR_MAX = 200; /** Ein OpenSSH-Private-Key mit RSA-16384 bleibt unter 16 KiB; mehr ist kein Schluessel. */ export const PRIVATE_KEY_MAX = 16 * 1024; export const PASSPHRASE_MAX = 1024; /** * Wie lange ein vom Worker geoeffneter Private Key zur Abholung bereitliegt (#014). Worker * (Aufraeumen) und Web (Abholen) pruefen dieselbe Frist. */ export const ABHOLFRIST_MS = 2 * 60 * 1000; /** Ab hier wird ein Ablaufdatum in Messing hervorgehoben. */ export const ABLAUF_WARNUNG_TAGE = 30; const UMLAUTE: Record = { ä: "ae", ö: "oe", ü: "ue", ß: "ss" }; /** * Slug aus dem Zweck: „Backup srv-backup01 → srv-pve01“ wird zu * `backup-srv-backup01-srv-pve01`. Ohne verwertbare Zeichen bleibt „schluessel“. */ export function slugAus(name: string): string { const slug = name .toLowerCase() .replace(/[äöüß]/g, (z) => UMLAUTE[z] ?? z) .normalize("NFKD") .replace(/[̀-ͯ]/g, "") .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 64) .replace(/-+$/, ""); return slug || "schluessel"; } /** Haengt `-2`, `-3` … an, bis `istFrei` zustimmt; kuerzt vorher, damit 64 Zeichen reichen. */ export async function freierSlug( basis: string, istFrei: (slug: string) => Promise, ): Promise { if (await istFrei(basis)) return basis; for (let n = 2; n < 1000; n++) { const anhang = `-${n}`; const kandidat = `${basis.slice(0, 64 - anhang.length).replace(/-+$/, "")}${anhang}`; if (await istFrei(kandidat)) return kandidat; } throw new Error("Kein freier Slug gefunden."); } /** Freitext aus einem Formular: leer heisst „nicht gesetzt“. */ const optionalerText = (max: number) => z .string() .trim() .max(max, `Höchstens ${max} Zeichen.`) .nullish() .transform((wert) => (wert ? wert : null)); const optionaleId = z .string() .trim() .max(64) .nullish() .transform((wert) => (wert ? wert : null)); const tagsSchema = z .preprocess( (wert) => (typeof wert === "string" ? wert.split(/[\s,]+/).filter(Boolean) : (wert ?? [])), z.array( z .string() .trim() .toLowerCase() .regex(TAG, "Tags: 1–32 Zeichen aus a–z, 0–9, Punkt, Unterstrich, Bindestrich."), ), ) .transform((tags) => [...new Set(tags)]) .refine((tags) => tags.length <= MAX_TAGS, `Höchstens ${MAX_TAGS} Tags.`); /** * Ablaufdatum als `JJJJ-MM-TT` aus ``. Gespeichert wird Mitternacht UTC * des Tages – `datum()` zeigt in Europe/Berlin denselben Kalendertag. */ const ablaufSchema = z .string() .trim() .nullish() .transform((wert, kontext) => { if (!wert) return null; if (!/^\d{4}-\d{2}-\d{2}$/.test(wert)) { kontext.addIssue({ code: "custom", message: "Bitte ein Datum angeben." }); return z.NEVER; } const datum = new Date(`${wert}T00:00:00Z`); if (Number.isNaN(datum.getTime()) || datum.toISOString().slice(0, 10) !== wert) { kontext.addIssue({ code: "custom", message: "Bitte ein gültiges Datum angeben." }); return z.NEVER; } return datum; }); /** Kommentar der Public-Key-Zeile: keine Steuerzeichen, sie braechen authorized_keys auf. */ const kommentarSchema = optionalerText(KOMMENTAR_MAX).refine( (wert) => wert === null || !/[\x00-\x1f\x7f]/.test(wert), "Der Kommentar enthält unzulässige Zeichen.", ); const slugSchema = z .string() .trim() .toLowerCase() .nullish() .transform((wert) => (wert ? wert : null)) .refine((wert) => wert === null || SLUG.test(wert), "1–64 Zeichen aus a–z, 0–9 und Bindestrich."); /** Metadaten, die jeder Schluessel hat – beim Anlegen und Bearbeiten. */ const metadatenFelder = { name: z.string().trim().min(1, "Bitte den Zweck angeben.").max(NAME_MAX, `Höchstens ${NAME_MAX} Zeichen.`), tags: tagsSchema, ablaufAm: ablaufSchema, personId: optionaleId, vorlageId: optionaleId, }; /** * Erzeugen (#012). Kategorie ist fest DIENST: Personen-Keys speichert sshkv nie mit Private * Key (DB-CHECK `schluessel_person_ohne_private_key`), Deployment-Keys verwaltet der Worker. */ export const erzeugenSchema = z.object({ ...metadatenFelder, slug: slugSchema, typ: z.enum(["ED25519", "RSA4096"]).default("ED25519"), kommentar: kommentarSchema, }); /** Import eines Paars: dieselben Regeln wie beim Erzeugen, der Typ steckt im Key selbst. */ export const paarImportSchema = z.object({ ...metadatenFelder, slug: slugSchema, kommentar: kommentarSchema, }); /** Import nur des Public Keys: auch fuer Personen-Keys. */ export const publicImportSchema = z.object({ ...metadatenFelder, slug: slugSchema, kategorie: z.enum(["PERSON", "DIENST"]).default("DIENST"), publicKey: z .string() .trim() .min(1, "Bitte den Public Key einfügen.") .max(16 * 1024, "Der Text ist zu lang für einen Public Key."), kommentar: kommentarSchema, }); /** * Bearbeiten: Slug, Typ und Kommentar bleiben – sie stehen schon in Dateinamen und * authorized_keys-Zeilen auf den Hosts. ROTIERT/WIDERRUFEN setzen nur Rotation und Widerruf * (#023, #024); von Hand gibt es nur AKTIV und ABGELAUFEN. */ export const bearbeitenSchema = z.object({ ...metadatenFelder, status: z.enum(["AKTIV", "ABGELAUFEN"]).optional(), }); /** Tags kommen als Text („pve, backup“) oder Liste; `z.preprocess` liesse sonst nur `unknown` uebrig. */ type MitTags = Omit & { tags?: string | string[] }; export type ErzeugenEingabe = MitTags>; export type ErzeugenDaten = z.output; export type PaarImportEingabe = MitTags>; export type PaarImportDaten = z.output; export type PublicImportEingabe = MitTags>; export type PublicImportDaten = z.output; export type BearbeitenEingabe = MitTags>; export type BearbeitenDaten = z.output; /** * Was ein Erzeugen- oder Import-Vorgang in `Vorgang.eingabe` traegt – nur Metadaten, nie ein * Geheimnis. Datumswerte als ISO-Text, weil `eingabe` JSON ist. */ export type SchluesselVorgangEingabe = { readonly name: string; readonly slug: string; readonly tags: string[]; readonly ablaufAm: string | null; readonly personId: string | null; readonly vorlageId: string | null; readonly kommentar: string | null; readonly typ?: "ED25519" | "RSA4096"; /** Nur Import: soll der Worker eine Passphrase erwarten? Die Passphrase selbst reist im Job. */ readonly mitPassphrase?: boolean; /** Rotation (#023): der abgeloeste Schluessel – so ist die Kette sofort da, auch ohne Browser. */ readonly vorgaengerId?: string | null; }; /** Prueft `Vorgang.eingabe` im Worker – sie lag zwischendurch in der Datenbank. */ export const vorgangEingabeSchema = z.object({ name: z.string().trim().min(1).max(NAME_MAX), slug: z.string().regex(SLUG), tags: z.array(z.string().regex(TAG)).max(MAX_TAGS), ablaufAm: z.iso.datetime().nullable(), personId: z.string().max(64).nullable(), vorlageId: z.string().max(64).nullable(), kommentar: z .string() .max(KOMMENTAR_MAX) .refine((wert) => !/[\x00-\x1f\x7f]/.test(wert)) .nullable(), typ: z.enum(["ED25519", "RSA4096"]).optional(), mitPassphrase: z.boolean().optional(), vorgaengerId: z.string().max(64).nullish(), }); /** Was im verschluesselten `geheim` eines Erzeugen- oder Import-Jobs steht. */ export type SchluesselGeheimnis = { readonly privateKey?: string; readonly passphrase?: string; }; // ---------------------------------------------------------------- Anzeige export type Kategorie = "PERSON" | "DIENST" | "DEPLOYMENT"; export type Status = "AKTIV" | "ROTIERT" | "WIDERRUFEN" | "ABGELAUFEN"; export const KATEGORIE_TEXT: Record = { PERSON: "Person", DIENST: "Dienst", DEPLOYMENT: "Deployment", }; export const STATUS_TEXT: Record = { AKTIV: "aktiv", ROTIERT: "rotiert", WIDERRUFEN: "widerrufen", ABGELAUFEN: "abgelaufen", }; /** Kurzform fuer Listen: `ed25519`, `rsa-4096`, sonst der Typ aus der Public-Key-Zeile. */ export function typAnzeige(typ: "ED25519" | "RSA4096" | "ANDERER", publicKey: string): string { if (typ === "ED25519") return "ed25519"; if (typ === "RSA4096") return "rsa-4096"; const algorithmus = publicKey.split(/\s+/)[0] ?? ""; if (algorithmus === "ssh-rsa") return "rsa"; return algorithmus.replace(/^ssh-/, "").replace(/@openssh\.com$/, "") || "unbekannt"; } /** `SHA256:q3vN8xP0…Qe0k` – lang genug zum Wiedererkennen, kurz genug fuer eine Tabellenzeile. */ export function fingerprintKurz(fingerprint: string): string { const [praefix, rest] = fingerprint.includes(":") ? fingerprint.split(":", 2) : ["", fingerprint]; const wert = rest ?? ""; if (wert.length <= 14) return fingerprint; return `${praefix ? `${praefix}:` : ""}${wert.slice(0, 8)}…${wert.slice(-4)}`; } export type AblaufZustand = | { readonly art: "keiner" } | { readonly art: "spaeter"; readonly tage: number } | { readonly art: "bald"; readonly tage: number } | { readonly art: "abgelaufen"; readonly tage: number }; /** Wie dringend ist das Ablaufdatum? `bald` heisst: in hoechstens 30 Tagen. */ export function ablaufZustand(ablaufAm: Date | string | null, jetzt: Date = new Date()): AblaufZustand { if (!ablaufAm) return { art: "keiner" }; const tage = tageBis(ablaufAm, jetzt); if (tage < 0) return { art: "abgelaufen", tage }; if (tage <= ABLAUF_WARNUNG_TAGE) return { art: "bald", tage }; return { art: "spaeter", tage }; } /** Typ fuer die Datenbank aus Algorithmus und Laenge: nur ed25519 und RSA-4096 haben eigene Werte. */ export function schluesselTypVon( algorithmus: string, bits: number | null, ): "ED25519" | "RSA4096" | "ANDERER" { if (algorithmus === "ssh-ed25519") return "ED25519"; if (algorithmus === "ssh-rsa" && bits === 4096) return "RSA4096"; return "ANDERER"; } /** Public-Key-Zeile mit dem gespeicherten Kommentar: `typ base64 kommentar`. */ export function publicKeyZeileMitKommentar(zeile: string, kommentar: string): string { const [typ, base64] = zeile.trim().split(/\s+/); return kommentar ? `${typ} ${base64} ${kommentar}` : `${typ} ${base64}`; }