Einen Schlüssel zu tauschen soll keinen Ausfall riskieren: Der Nachfolger übernimmt alle aktiven Zuweisungen, wird ausgerollt und per Verbindungstest geprüft – erst wenn alle Tests seit dem letzten Ausrollen grün sind, entfernt der Widerruf aus #024 den alten Schlüssel, der dann als ROTIERT endet. Private Keys bekommen einen neuen Pfad (-rJJJJMMTT) bei gleichem Alias. Damit ssh den neuen nimmt, schreibt privat.ts je Alias nur noch einen config-Eintrag; die jüngste Zuweisung gewinnt. Der Worker setzt vorgaengerId schon beim Erzeugen, so ist die Kette auch nach einem Abbruch vollständig und die Schrittfolge wiederaufnehmbar. Personen-Keys ohne Private Key in sshkv bekommen nur einen Hinweis: dafür braucht es einen neuen Public Key von der Person. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
296 lines
11 KiB
TypeScript
296 lines
11 KiB
TypeScript
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<string, string> = { ä: "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<boolean>,
|
||
): Promise<string> {
|
||
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 `<input type="date">`. 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<T> = Omit<T, "tags"> & { tags?: string | string[] };
|
||
export type ErzeugenEingabe = MitTags<z.input<typeof erzeugenSchema>>;
|
||
export type ErzeugenDaten = z.output<typeof erzeugenSchema>;
|
||
export type PaarImportEingabe = MitTags<z.input<typeof paarImportSchema>>;
|
||
export type PaarImportDaten = z.output<typeof paarImportSchema>;
|
||
export type PublicImportEingabe = MitTags<z.input<typeof publicImportSchema>>;
|
||
export type PublicImportDaten = z.output<typeof publicImportSchema>;
|
||
export type BearbeitenEingabe = MitTags<z.input<typeof bearbeitenSchema>>;
|
||
export type BearbeitenDaten = z.output<typeof bearbeitenSchema>;
|
||
|
||
/**
|
||
* 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<Kategorie, string> = {
|
||
PERSON: "Person",
|
||
DIENST: "Dienst",
|
||
DEPLOYMENT: "Deployment",
|
||
};
|
||
|
||
export const STATUS_TEXT: Record<Status, string> = {
|
||
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}`;
|
||
}
|