feat(schluessel): erzeugen, importieren, exportieren (#012)
Schluessel werden jetzt verwaltet statt nur im Prototyp gezeigt: Liste mit Filter, Anlegen (erzeugen oder importieren), Detail nach dem Prototyp aus #002, Bearbeiten der Metadaten und Download <slug>.pub. Warum so: - Erzeugen und Import eines Paars laufen als Vorgang im Worker, weil nur er den Master-Key hat. Passphrase und importierter Private Key reisen wie das Bootstrap-Passwort mit SSHKV_APP_SECRET verschluesselt im Job, nie in Vorgang.eingabe; der Worker loescht den Job danach. - Ein reiner Public Key ist kein Geheimnis und wird direkt im Web angelegt, mit verstaendlicher Meldung bei einem Duplikat. - Das Web liest nie das Chiffrat: ob ein Private Key gespeichert ist, verraet masterKeyVersion (per CHECK an das Chiffrat gekoppelt). Ein struktureller Test verlangt fuer jede zeilenliefernde Abfrage auf Schluessel ausserhalb des Workers ein festes select. - Migration: Vorgangsarten fuer Erzeugen, Import und (fuer #014) das Loeschen des Private Keys; CHECKs fuer den Slug (landet in Dateinamen) und "geloescht nur ohne Chiffrat". - Platzhalter PublicAusrollenAktion/PrivateAusrollenAktion fuer #016/#017. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,23 +1,60 @@
|
|||||||
# #012 Schlüssel erzeugen, importieren, exportieren
|
# #012 Schlüssel erzeugen, importieren, exportieren
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.2.0
|
**Meilenstein:** v0.2.0
|
||||||
**Labels:** backend, ui
|
**Labels:** backend, ui
|
||||||
**Geschätzt:** 4 h **Tatsächlich:** –
|
**Geschätzt:** 4 h **Tatsächlich:** 0 h 25 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Schlüsselliste und -formular: erzeugen (ed25519, rsa-4096), importieren (Public oder Paar), exportieren; Kategorien, Tags, Ablaufdatum.
|
Schlüsselliste und -formular: erzeugen (ed25519, rsa-4096), importieren (Public oder Paar), exportieren; Kategorien, Tags, Ablaufdatum.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Liste mit Filter
|
- [x] Liste mit Filter
|
||||||
- [ ] Erzeugen-Dialog
|
- [x] Erzeugen-Dialog
|
||||||
- [ ] Import mit Prüfung
|
- [x] Import mit Prüfung
|
||||||
- [ ] Export Public Key
|
- [x] Export Public Key
|
||||||
- [ ] Schlüsseldetail-Gerüst mit Verwendungen
|
- [x] Schlüsseldetail-Gerüst mit Verwendungen
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Private Keys erscheinen nie in Listen-Antworten
|
- [x] Private Keys erscheinen nie in Listen-Antworten
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#006
|
#006
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Migration `20261012100000_schluessel_vorgaenge`:** Vorgangsarten `SCHLUESSEL_ERZEUGEN`,
|
||||||
|
`SCHLUESSEL_IMPORTIEREN`, `PRIVATE_KEY_LOESCHEN` (für #014 gleich mit), CHECK
|
||||||
|
`schluessel_slug_gueltig` (`^[a-z0-9-]{1,64}$`, der Slug landet in Dateinamen) und
|
||||||
|
`schluessel_geloescht_ohne_private_key`.
|
||||||
|
- **Erzeugen und Paar-Import laufen im Worker** (`src/worker/ablaeufe/schluessel.ts`). Das Web
|
||||||
|
prüft nur Metadaten (Slug frei, Person/Vorlage vorhanden, Ablauf nicht in der Vergangenheit)
|
||||||
|
und reiht den Vorgang ein. Passphrase und importierter Private Key reisen wie das
|
||||||
|
Bootstrap-Passwort mit `SSHKV_APP_SECRET` verschlüsselt im Job (`geheim`), nie in
|
||||||
|
`Vorgang.eingabe`; der Worker löscht den Job danach. Der Worker prüft die Metadaten erneut.
|
||||||
|
- **Kategorie:** Erzeugen und Paar-Import legen immer `DIENST` an. Personen-Keys speichert sshkv
|
||||||
|
nie mit Private Key (DB-CHECK), Deployment-Keys verwaltet der Worker selbst. Der
|
||||||
|
Public-Key-Import kennt `PERSON` und `DIENST`.
|
||||||
|
- **Passphrase:** beim Erzeugen optional; gespeichert wird dann der passphrasengeschützte
|
||||||
|
OpenSSH-Key (`passphraseGeschuetzt = true`). Beim Import dient sie nur zum Lesen von Public Key
|
||||||
|
und Fingerprint und wird verworfen; gespeichert wird der Key so, wie er kam. Hinweis in der
|
||||||
|
Oberfläche: „Für automatische Anmeldungen (Host-zu-Host) ungeeignet.“
|
||||||
|
- **Typ:** `RSA4096` nur bei genau 4096 Bit, sonst `ANDERER` (z. B. RSA-3072, ecdsa). Die RSA-
|
||||||
|
Mindestlänge 2048 gilt beim Import.
|
||||||
|
- **Slug:** aus dem Zweck abgeleitet (Umlaute gefaltet), bei Kollision `-2`, `-3` …; ein
|
||||||
|
ausdrücklich gewünschter Kurzname muss frei sein. Slug, Typ und Kommentar sind danach nicht
|
||||||
|
mehr änderbar – sie stehen schon in Dateinamen und authorized_keys-Zeilen.
|
||||||
|
- **Status von Hand** nur `AKTIV` ↔ `ABGELAUFEN`; `ROTIERT`/`WIDERRUFEN` setzen #023/#024,
|
||||||
|
Deployment-Keys sind ausgenommen (sonst erzeugte der Worker beim Start einen neuen).
|
||||||
|
- **Ablaufdatum** ohne Angabe aus `Vorlage.standardLaufzeitTage`.
|
||||||
|
- **„Private Key gespeichert?“** liest das Web aus `masterKeyVersion` (der CHECK
|
||||||
|
`schluessel_umschlag_vollstaendig` koppelt sie an das Chiffrat) – das Chiffrat wird im Web
|
||||||
|
nie selektiert.
|
||||||
|
- **Grenze abgesichert:** `tests/schluessel-grenze.test.ts` verlangt für jeden zeilenliefernden
|
||||||
|
`prisma.schluessel.*`-Aufruf außerhalb von `src/worker` ein `select` und verbietet
|
||||||
|
`schluessel: true` als Relation; `tests/schluessel.test.ts` prüft, dass weder Vorgang,
|
||||||
|
Protokoll noch pg-boss-Job nach Erzeugen/Import den Key oder die Passphrase enthalten.
|
||||||
|
- **Platzhalter** für #016/#017: `src/app/(app)/schluessel/[id]/PublicAusrollenAktion.tsx` und
|
||||||
|
`PrivateAusrollenAktion.tsx`, Props `{ schluesselId: string; darf: boolean }`, rendern `null`.
|
||||||
|
- Personen-Keys zeigen im Detail nur die Karte „Public Key ausgerollt auf“ (eine Private-Karte
|
||||||
|
wäre immer leer).
|
||||||
|
- Prototyp `/schluessel/beispiel` samt `src/beispiel/schluessel.ts` entfernt; die Übersicht
|
||||||
|
(noch Prototyp, #026) verlinkt auf `/schluessel`.
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Schluessel erzeugen, importieren, Private Key loeschen (#012, #014).
|
||||||
|
--
|
||||||
|
-- Neue Vorgangsarten: Erzeugen und Import eines Paars laufen im Worker, weil nur er den
|
||||||
|
-- Master-Key hat. Das Loeschen eines gespeicherten Private Keys laeuft ebenfalls dort, damit
|
||||||
|
-- die Grenze „nur src/worker/geheimnisse.ts fasst das Chiffrat an“ einfach bleibt.
|
||||||
|
ALTER TYPE "VorgangsArt" ADD VALUE 'SCHLUESSEL_ERZEUGEN';
|
||||||
|
ALTER TYPE "VorgangsArt" ADD VALUE 'SCHLUESSEL_IMPORTIEREN';
|
||||||
|
ALTER TYPE "VorgangsArt" ADD VALUE 'PRIVATE_KEY_LOESCHEN';
|
||||||
|
|
||||||
|
-- Der Slug landet in Dateinamen (<slug>.pub, ~/.ssh/id_<slug>) und Kommentaren. Was hier
|
||||||
|
-- nicht passt, kommt nie bis in einen Pfad oder ein Shell-Skript.
|
||||||
|
ALTER TABLE "Schluessel" ADD CONSTRAINT "schluessel_slug_gueltig" CHECK (
|
||||||
|
"slug" ~ '^[a-z0-9-]{1,64}$'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- „Private Key geloescht“ und ein gespeichertes Chiffrat schliessen sich aus (#014).
|
||||||
|
ALTER TABLE "Schluessel" ADD CONSTRAINT "schluessel_geloescht_ohne_private_key" CHECK (
|
||||||
|
"privatGeloeschtAm" IS NULL OR "privatVerschluesselt" IS NULL
|
||||||
|
);
|
||||||
@@ -104,6 +104,12 @@ enum VorgangsArt {
|
|||||||
ROTATION
|
ROTATION
|
||||||
WIDERRUF
|
WIDERRUF
|
||||||
PRIVATE_KEY_ABHOLEN
|
PRIVATE_KEY_ABHOLEN
|
||||||
|
/// Schluesselpaar im Worker erzeugen und versiegeln (#012).
|
||||||
|
SCHLUESSEL_ERZEUGEN
|
||||||
|
/// Importiertes Paar im Worker pruefen und versiegeln (#012).
|
||||||
|
SCHLUESSEL_IMPORTIEREN
|
||||||
|
/// Gespeicherten Private Key endgueltig verwerfen (#014).
|
||||||
|
PRIVATE_KEY_LOESCHEN
|
||||||
}
|
}
|
||||||
|
|
||||||
enum VorgangsStatus {
|
enum VorgangsStatus {
|
||||||
|
|||||||
@@ -124,10 +124,99 @@ async function demoDaten(): Promise<void> {
|
|||||||
console.log(`Demo-Daten geprüft: ${DEMO_HOSTS.length} Hosts, ${DEMO_GRUPPEN.length} Gruppen.`);
|
console.log(`Demo-Daten geprüft: ${DEMO_HOSTS.length} Hosts, ${DEMO_GRUPPEN.length} Gruppen.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demo-Personen und -Schluessel (#012, #013). Nur Public Keys: einen Private Key kann nur
|
||||||
|
* der Worker versiegeln. Die Paare werden hier frisch erzeugt und der Private Key sofort
|
||||||
|
* verworfen – es sind Attrappen, die nirgends Zugang geben.
|
||||||
|
*/
|
||||||
|
const DEMO_PERSONEN = [
|
||||||
|
{ name: "Florian Wilde", email: "florian@example.org", organisation: "IT" },
|
||||||
|
{ name: "Anna Berger", email: "a.berger@netzwerk-gmbh.example", organisation: "Netzwerk GmbH" },
|
||||||
|
{ name: "Mehmet Yılmaz", email: "mehmet@example.org", organisation: "IT" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const TAG_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const DEMO_SCHLUESSEL: {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
kategorie: "PERSON" | "DIENST";
|
||||||
|
person?: string;
|
||||||
|
tags: string[];
|
||||||
|
ablaufInTagen?: number;
|
||||||
|
kommentar: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
name: "Laptop Florian",
|
||||||
|
slug: "laptop-florian",
|
||||||
|
kategorie: "PERSON",
|
||||||
|
person: "Florian Wilde",
|
||||||
|
tags: ["laptop"],
|
||||||
|
kommentar: "florian@laptop",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Wartungszugang Netzwerk GmbH",
|
||||||
|
slug: "wartung-netzwerk-gmbh",
|
||||||
|
kategorie: "PERSON",
|
||||||
|
person: "Anna Berger",
|
||||||
|
tags: ["dienstleister"],
|
||||||
|
ablaufInTagen: 18,
|
||||||
|
kommentar: "anna@netzwerk-gmbh",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Workstation Mehmet",
|
||||||
|
slug: "workstation-mehmet",
|
||||||
|
kategorie: "PERSON",
|
||||||
|
person: "Mehmet Yılmaz",
|
||||||
|
tags: [],
|
||||||
|
ablaufInTagen: 200,
|
||||||
|
kommentar: "mehmet@ws-07",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Monitoring zabbix → alle Hosts",
|
||||||
|
slug: "monitoring-zabbix",
|
||||||
|
kategorie: "DIENST",
|
||||||
|
tags: ["monitoring"],
|
||||||
|
kommentar: "zabbix@srv-mon01",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function demoSchluessel(): Promise<void> {
|
||||||
|
if (process.env["SSHKV_SEED_DEMO"] !== "1") return;
|
||||||
|
const { schluesselpaarErzeugen } = await import("../src/lib/ssh/openssh");
|
||||||
|
const personen = new Map<string, string>();
|
||||||
|
for (const p of DEMO_PERSONEN) {
|
||||||
|
const vorhanden = await prisma.person.findFirst({ where: { name: p.name }, select: { id: true } });
|
||||||
|
const person = vorhanden ?? (await prisma.person.create({ data: p, select: { id: true } }));
|
||||||
|
personen.set(p.name, person.id);
|
||||||
|
}
|
||||||
|
for (const s of DEMO_SCHLUESSEL) {
|
||||||
|
if (await prisma.schluessel.findUnique({ where: { slug: s.slug }, select: { id: true } })) continue;
|
||||||
|
const paar = await schluesselpaarErzeugen("ED25519", { kommentar: s.kommentar });
|
||||||
|
await prisma.schluessel.create({
|
||||||
|
data: {
|
||||||
|
name: s.name,
|
||||||
|
slug: s.slug,
|
||||||
|
typ: "ED25519",
|
||||||
|
kategorie: s.kategorie,
|
||||||
|
publicKey: paar.publicKeyZeile,
|
||||||
|
fingerprint: paar.fingerprint,
|
||||||
|
kommentar: s.kommentar,
|
||||||
|
tags: s.tags,
|
||||||
|
personId: s.person ? (personen.get(s.person) ?? null) : null,
|
||||||
|
ablaufAm: s.ablaufInTagen ? new Date(Date.now() + s.ablaufInTagen * TAG_MS) : null,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`Demo-Daten geprüft: ${DEMO_PERSONEN.length} Personen, ${DEMO_SCHLUESSEL.length} Schlüssel.`);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await notfallAdmin();
|
await notfallAdmin();
|
||||||
await vorlagen();
|
await vorlagen();
|
||||||
await demoDaten();
|
await demoDaten();
|
||||||
|
await demoSchluessel();
|
||||||
} finally {
|
} finally {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,14 @@ const ZIEL = process.env["SSHKV_BILDER"] ?? join(process.cwd(), "docs/anleitung/
|
|||||||
const SEITEN: { name: string; pfad: string; oeffentlich?: boolean; folgen?: string }[] = [
|
const SEITEN: { name: string; pfad: string; oeffentlich?: boolean; folgen?: string }[] = [
|
||||||
{ name: "anmelden", pfad: "/anmelden", oeffentlich: true },
|
{ name: "anmelden", pfad: "/anmelden", oeffentlich: true },
|
||||||
{ name: "uebersicht", pfad: "/" },
|
{ name: "uebersicht", pfad: "/" },
|
||||||
{ name: "schluessel-detail", pfad: "/schluessel/beispiel" },
|
{ name: "schluessel", pfad: "/schluessel" },
|
||||||
|
{ name: "schluessel-neu", pfad: "/schluessel/neu" },
|
||||||
|
// Der Demo-Key mit nahem Ablaufdatum zeigt die meisten Badges (SSHKV_SEED_DEMO=1, #012).
|
||||||
|
{
|
||||||
|
name: "schluessel-detail",
|
||||||
|
pfad: "/schluessel?q=wartung",
|
||||||
|
folgen: 'main table a[href^="/schluessel/"]',
|
||||||
|
},
|
||||||
{ name: "hosts", pfad: "/hosts" },
|
{ name: "hosts", pfad: "/hosts" },
|
||||||
{ name: "host-detail", pfad: "/hosts", folgen: 'main table a[href^="/hosts/"]' },
|
{ name: "host-detail", pfad: "/hosts", folgen: 'main table a[href^="/hosts/"]' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export default async function Uebersicht() {
|
|||||||
return (
|
return (
|
||||||
<li key={s.zweck} className="border-border border-b last:border-b-0">
|
<li key={s.zweck} className="border-border border-b last:border-b-0">
|
||||||
<Link
|
<Link
|
||||||
href="/schluessel/beispiel"
|
href="/schluessel"
|
||||||
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 items-center justify-between gap-3 px-2 py-2.5 transition-colors"
|
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 items-center justify-between gap-3 px-2 py-2.5 transition-colors"
|
||||||
>
|
>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platzhalter fuer „+ Ausrollen“ in der Karte „Private Key ausgerollt auf“ (#012).
|
||||||
|
* Inhalt folgt mit #017 (Private Key auf einen Host legen); bis dahin bewusst leer.
|
||||||
|
*/
|
||||||
|
export function PrivateAusrollenAktion(props: { schluesselId: string; darf: boolean }): null {
|
||||||
|
void props;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platzhalter fuer „+ Ausrollen“ in der Karte „Public Key ausgerollt auf“ (#012).
|
||||||
|
* Inhalt folgt mit #016 (Zuweisung, Dry-Run, Ausrollen); bis dahin bewusst leer.
|
||||||
|
*/
|
||||||
|
export function PublicAusrollenAktion(props: { schluesselId: string; darf: boolean }): null {
|
||||||
|
void props;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { SchluesselBearbeiten } from "@/components/schluessel/SchluesselBearbeiten";
|
||||||
|
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { personenAuswahl, schluesselDetail, vorlagenAuswahl } from "@/server/queries/schluessel";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Schlüssel bearbeiten" };
|
||||||
|
|
||||||
|
export default async function SchluesselBearbeitenSeite({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
await sitzungErforderlich("schluesselVerwalten", `/schluessel/${id}/bearbeiten`);
|
||||||
|
const [s, personen, vorlagen] = await Promise.all([
|
||||||
|
schluesselDetail(id),
|
||||||
|
personenAuswahl(),
|
||||||
|
vorlagenAuswahl(),
|
||||||
|
]);
|
||||||
|
if (!s) notFound();
|
||||||
|
|
||||||
|
// Eine inzwischen archivierte Person oder Vorlage bleibt waehlbar, solange sie zugeordnet
|
||||||
|
// ist – sonst loeschte schon das blosse Speichern die Zuordnung.
|
||||||
|
const personListe = personen.map((p) => ({ id: p.id, name: p.name, zusatz: p.organisation }));
|
||||||
|
if (s.person && !personListe.some((p) => p.id === s.person?.id))
|
||||||
|
personListe.push({ ...s.person, zusatz: null });
|
||||||
|
const vorlagenListe = vorlagen.map((v) => ({ id: v.id, name: v.name }));
|
||||||
|
if (s.vorlage && !vorlagenListe.some((v) => v.id === s.vorlage?.id)) vorlagenListe.push(s.vorlage);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
oben={
|
||||||
|
<Breadcrumb
|
||||||
|
pfad={[
|
||||||
|
{ titel: "Schlüssel", href: "/schluessel" },
|
||||||
|
{ titel: s.name, href: `/schluessel/${s.id}` },
|
||||||
|
{ titel: "Bearbeiten" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
titel="Schlüssel bearbeiten"
|
||||||
|
untertitel={
|
||||||
|
<>
|
||||||
|
Kurzname <span className="text-text font-mono">{s.slug}</span> und Kommentar bleiben unverändert.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SchluesselBearbeiten
|
||||||
|
werte={{
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
tags: s.tags,
|
||||||
|
ablaufAm: s.ablaufAm ? s.ablaufAm.toISOString().slice(0, 10) : "",
|
||||||
|
personId: s.personId ?? "",
|
||||||
|
vorlageId: s.vorlageId ?? "",
|
||||||
|
status: s.status,
|
||||||
|
statusAenderbar:
|
||||||
|
s.kategorie !== "DEPLOYMENT" && (s.status === "AKTIV" || s.status === "ABGELAUFEN"),
|
||||||
|
}}
|
||||||
|
personen={personListe}
|
||||||
|
vorlagen={vorlagenListe}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { Download, Lock, LockOpen, Pencil } from "lucide-react";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { AblaufBadge, KategorieBadge, StatusBadge, TypBadge } from "@/components/schluessel/Anzeige";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { InnereBox, Karte } from "@/components/ui/Karte";
|
||||||
|
import { MonoBlock } from "@/components/ui/MonoBlock";
|
||||||
|
import { MonoKopieren } from "@/components/ui/MonoKopieren";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { Statuspunkt, type HostStatus } from "@/components/ui/Statuspunkt";
|
||||||
|
import { datum, datumZeit } from "@/lib/format";
|
||||||
|
import { darf } from "@/server/auth/rechte";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { schluesselDetail, type SchluesselDetail } from "@/server/queries/schluessel";
|
||||||
|
import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion";
|
||||||
|
import { PublicAusrollenAktion } from "./PublicAusrollenAktion";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Schlüssel" };
|
||||||
|
|
||||||
|
function InfoFeld({ titel, children }: { titel: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 px-5 py-4 sm:px-6">
|
||||||
|
<dt className="text-muted mb-1.5 text-[13px] font-medium">{titel}</dt>
|
||||||
|
<dd className="min-w-0">{children}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Soll-Zustand einer Verwendung als Statuspunkt – Form und Text tragen die Information mit. */
|
||||||
|
const ZUSTAND: Record<string, { status: HostStatus; text: string }> = {
|
||||||
|
GEPLANT: { status: "unbekannt", text: "geplant" },
|
||||||
|
AUSGEROLLT: { status: "synchron", text: "ausgerollt" },
|
||||||
|
ABWEICHUNG: { status: "drift", text: "Abweichung" },
|
||||||
|
ENTFERNEN: { status: "unbekannt", text: "wird entfernt" },
|
||||||
|
FEHLGESCHLAGEN: { status: "drift", text: "fehlgeschlagen" },
|
||||||
|
};
|
||||||
|
|
||||||
|
type Eintrag = { id: string; ziel: string; href?: string; pfad: string; zusatz?: string; zustand: string };
|
||||||
|
|
||||||
|
function AusrollKarte({
|
||||||
|
titel,
|
||||||
|
untertitel,
|
||||||
|
eintraege,
|
||||||
|
leer,
|
||||||
|
aktion,
|
||||||
|
}: {
|
||||||
|
titel: string;
|
||||||
|
untertitel: string;
|
||||||
|
eintraege: Eintrag[];
|
||||||
|
leer: string;
|
||||||
|
aktion: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Karte titel={titel} untertitel={untertitel} aktionen={aktion}>
|
||||||
|
{eintraege.length === 0 ? (
|
||||||
|
<p className="text-muted border-border-strong rounded-box border border-dashed px-4 py-6 text-center text-[14px]">
|
||||||
|
{leer}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-3">
|
||||||
|
{eintraege.map((e) => {
|
||||||
|
const z = ZUSTAND[e.zustand] ?? { status: "unbekannt" as const, text: e.zustand };
|
||||||
|
return (
|
||||||
|
<li key={e.id}>
|
||||||
|
<InnereBox className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-text font-mono text-[13.5px] font-medium break-all">
|
||||||
|
{e.href ? (
|
||||||
|
<Link href={e.href} className="hover:text-accent transition-colors">
|
||||||
|
{e.ziel}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
e.ziel
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted mt-1 font-mono text-[12.5px] break-all">{e.pfad}</p>
|
||||||
|
{e.zusatz && <p className="text-muted font-mono text-[12.5px] break-all">{e.zusatz}</p>}
|
||||||
|
</div>
|
||||||
|
<Statuspunkt status={z.status} text={z.text} className="shrink-0 sm:pt-0.5" />
|
||||||
|
</InnereBox>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function privateEintraege(s: SchluesselDetail): Eintrag[] {
|
||||||
|
return s.privateVerwendungen.map((v) => {
|
||||||
|
const config =
|
||||||
|
v.sshConfig && typeof v.sshConfig === "object" ? (v.sshConfig as { alias?: unknown }) : null;
|
||||||
|
return {
|
||||||
|
id: v.id,
|
||||||
|
ziel: `${v.zielbenutzer}@${v.host.name}`,
|
||||||
|
href: `/hosts/${v.host.id}`,
|
||||||
|
pfad: v.pfad,
|
||||||
|
...(typeof config?.alias === "string" ? { zusatz: `config: Host ${config.alias}` } : {}),
|
||||||
|
zustand: v.zustand,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicEintraege(s: SchluesselDetail): Eintrag[] {
|
||||||
|
return s.publicVerwendungen.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
ziel: v.host
|
||||||
|
? `${v.zielbenutzer}@${v.host.name}`
|
||||||
|
: `${v.zielbenutzer}@${v.hostgruppe?.name ?? "?"} (Gruppe)`,
|
||||||
|
...(v.host
|
||||||
|
? { href: `/hosts/${v.host.id}` }
|
||||||
|
: v.hostgruppe
|
||||||
|
? { href: `/hostgruppen/${v.hostgruppe.id}` }
|
||||||
|
: {}),
|
||||||
|
pfad: "~/.ssh/authorized_keys",
|
||||||
|
...(v.optionen ? { zusatz: v.optionen } : {}),
|
||||||
|
zustand: v.zustand,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was mit dem Private Key ist – ohne ihn je zu lesen (masterKeyVersion verraet es). */
|
||||||
|
function PrivatStatus({ s, anzeigen }: { s: SchluesselDetail; anzeigen?: ReactNode }) {
|
||||||
|
if (s.privatGespeichert) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex flex-wrap items-center gap-x-1.5 text-[14px]">
|
||||||
|
<Lock className="text-ok size-3.5" aria-hidden />
|
||||||
|
verschlüsselt gespeichert
|
||||||
|
{s.passphraseGeschuetzt && <span className="text-muted">· mit Passphrase</span>}
|
||||||
|
{anzeigen}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (s.privatGeloeschtAm) {
|
||||||
|
return (
|
||||||
|
<span className="text-muted inline-flex items-center gap-1.5 text-[14px]">
|
||||||
|
<LockOpen className="size-3.5" aria-hidden />
|
||||||
|
gelöscht am <time dateTime={s.privatGeloeschtAm.toISOString()}>{datum(s.privatGeloeschtAm)}</time>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="text-muted text-[14px]">
|
||||||
|
nicht gespeichert{s.kategorie === "PERSON" ? " (Personen-Key)" : ""}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schlüsseldetail (#012) nach dem Prototyp aus #002; Ausrollen folgt mit #016/#017. */
|
||||||
|
export default async function SchluesselSeite({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const { benutzer } = await sitzungErforderlich("lesen", `/schluessel/${id}`);
|
||||||
|
const s = await schluesselDetail(id);
|
||||||
|
if (!s) notFound();
|
||||||
|
|
||||||
|
const aktiv = s.status === "AKTIV";
|
||||||
|
const personenKey = s.kategorie === "PERSON";
|
||||||
|
const verwalten = darf(benutzer.rolle, "schluesselVerwalten");
|
||||||
|
const jetzt = new Date();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
oben={<Breadcrumb pfad={[{ titel: "Schlüssel", href: "/schluessel" }, { titel: s.name }]} />}
|
||||||
|
titel={s.name}
|
||||||
|
untertitel={
|
||||||
|
s.person ? (
|
||||||
|
<>
|
||||||
|
Person: <span className="text-text">{s.person.name}</span>
|
||||||
|
</>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
badges={
|
||||||
|
<>
|
||||||
|
<TypBadge typ={s.typ} publicKey={s.publicKey} />
|
||||||
|
<KategorieBadge kategorie={s.kategorie} />
|
||||||
|
<StatusBadge status={s.status} />
|
||||||
|
{s.instanzStandard && <Badge variante="messing">Instanz-Standard</Badge>}
|
||||||
|
{s.selbstHinterlegt && <Badge>selbst hinterlegt</Badge>}
|
||||||
|
{s.vorlage && <Badge>Vorlage: {s.vorlage.name}</Badge>}
|
||||||
|
<AblaufBadge ablaufAm={s.ablaufAm} jetzt={jetzt} lang />
|
||||||
|
{s.tags.map((t) => (
|
||||||
|
<Badge key={t} mono className="text-muted">
|
||||||
|
#{t}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
aktionen={
|
||||||
|
verwalten && (
|
||||||
|
<Button href={`/schluessel/${s.id}/bearbeiten`} icon={<Pencil className="size-4" />}>
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Karte as="div" innen="ohne">
|
||||||
|
<dl className="divide-border grid divide-y md:grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,1.1fr)] md:divide-x md:divide-y-0">
|
||||||
|
<InfoFeld titel="Fingerprint">
|
||||||
|
<MonoKopieren wert={s.fingerprint} beschriftung="Fingerprint" umbrechen className="-my-[11px]" />
|
||||||
|
</InfoFeld>
|
||||||
|
<InfoFeld titel="Erstellt">
|
||||||
|
<time dateTime={s.angelegtAm.toISOString()} className="font-mono text-[13px]">
|
||||||
|
{datumZeit(s.angelegtAm)}
|
||||||
|
</time>
|
||||||
|
<span className="text-muted block text-[13px]">
|
||||||
|
{s.erstelltVon ? `von ${s.erstelltVon.name}` : "von sshkv"}
|
||||||
|
</span>
|
||||||
|
</InfoFeld>
|
||||||
|
<InfoFeld titel="Private Key">
|
||||||
|
<PrivatStatus s={s} />
|
||||||
|
</InfoFeld>
|
||||||
|
</dl>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
{/* Personen-Keys haben nie einen gespeicherten Private Key (DB-CHECK) – die Karte dafuer
|
||||||
|
waere immer leer. */}
|
||||||
|
<div className={personenKey ? "grid gap-6" : "grid gap-6 lg:grid-cols-2"}>
|
||||||
|
{!personenKey && (
|
||||||
|
<AusrollKarte
|
||||||
|
titel="Private Key ausgerollt auf"
|
||||||
|
untertitel="Hosts, die sich mit diesem Schlüssel anmelden"
|
||||||
|
eintraege={privateEintraege(s)}
|
||||||
|
leer={
|
||||||
|
s.privatGespeichert
|
||||||
|
? "Der Private Key liegt noch auf keinem Host."
|
||||||
|
: "Ohne gespeicherten Private Key kann sshkv ihn nicht ausrollen."
|
||||||
|
}
|
||||||
|
aktion={
|
||||||
|
<PrivateAusrollenAktion
|
||||||
|
schluesselId={s.id}
|
||||||
|
darf={aktiv && s.privatGespeichert && darf(benutzer.rolle, "privateKeyAusrollen")}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<AusrollKarte
|
||||||
|
titel="Public Key ausgerollt auf"
|
||||||
|
untertitel="Konten, die diesen Schlüssel annehmen"
|
||||||
|
eintraege={publicEintraege(s)}
|
||||||
|
leer={
|
||||||
|
personenKey
|
||||||
|
? "Noch keinem Konto zugewiesen – wirksam erst, wenn ein Operator oder Admin den Schlüssel einem Host zuweist."
|
||||||
|
: "Noch keinem Konto zugewiesen."
|
||||||
|
}
|
||||||
|
aktion={
|
||||||
|
<PublicAusrollenAktion
|
||||||
|
schluesselId={s.id}
|
||||||
|
darf={aktiv && darf(benutzer.rolle, "publicKeyAusrollen")}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Karte
|
||||||
|
titel="Public Key"
|
||||||
|
untertitel={
|
||||||
|
<>
|
||||||
|
Kommentar: <span className="font-mono">{s.kommentar || "–"}</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
aktionen={
|
||||||
|
<a
|
||||||
|
href={`/api/schluessel/${s.id}/public-key`}
|
||||||
|
download={`${s.slug}.pub`}
|
||||||
|
className="border-border-strong bg-surface text-text hover:bg-raised rounded-box inline-flex min-h-11 items-center gap-2 border px-3 text-[13px] font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Download className="size-4" aria-hidden />
|
||||||
|
{s.slug}.pub
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MonoBlock wert={s.publicKey} beschriftung="Public Key" umbrechen />
|
||||||
|
</Karte>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
import { Ban, Check, Lock, PlugZap, Plus, RotateCw } from "lucide-react";
|
|
||||||
import type { Metadata } from "next";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import {
|
|
||||||
beispielSchluessel as s,
|
|
||||||
dryRunDatei,
|
|
||||||
dryRunDiff,
|
|
||||||
privatAusgerollt,
|
|
||||||
publicAusgerollt,
|
|
||||||
zusatzaktionen,
|
|
||||||
type Ausrollung,
|
|
||||||
} from "@/beispiel/schluessel";
|
|
||||||
import { BEISPIEL_JETZT } from "@/beispiel/uebersicht";
|
|
||||||
import { Badge } from "@/components/ui/Badge";
|
|
||||||
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
|
||||||
import { Button } from "@/components/ui/Button";
|
|
||||||
import { DiffBox } from "@/components/ui/DiffBox";
|
|
||||||
import { InnereBox, Karte } from "@/components/ui/Karte";
|
|
||||||
import { MonoKopieren } from "@/components/ui/MonoKopieren";
|
|
||||||
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
|
||||||
import { Statuspunkt } from "@/components/ui/Statuspunkt";
|
|
||||||
import { datum, datumZeit, tageBis } from "@/lib/format";
|
|
||||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
|
||||||
|
|
||||||
export const metadata: Metadata = { title: `${s.zweck} (Beispiel)` };
|
|
||||||
|
|
||||||
/** Prototyp des Schlüsseldetails mit Beispieldaten (#002); echte Daten ab #012/#016/#017. */
|
|
||||||
export default async function SchluesselBeispiel() {
|
|
||||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
|
||||||
await sitzungErforderlich();
|
|
||||||
const tage = tageBis(s.ablauf, BEISPIEL_JETZT);
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-6 sm:gap-7">
|
|
||||||
<Seitenkopf
|
|
||||||
oben={<Breadcrumb pfad={[{ titel: "Schlüssel", href: "/schluessel" }, { titel: s.zweck }]} />}
|
|
||||||
titel={s.zweck}
|
|
||||||
badges={
|
|
||||||
<>
|
|
||||||
<Badge mono>{s.typ}</Badge>
|
|
||||||
<Badge>{s.kategorie}</Badge>
|
|
||||||
<Badge>Vorlage: {s.vorlage}</Badge>
|
|
||||||
<Badge variante="messing">
|
|
||||||
<span>
|
|
||||||
läuft ab am <time dateTime={s.ablauf}>{datum(s.ablauf)}</time> · in {tage} Tagen
|
|
||||||
</span>
|
|
||||||
</Badge>
|
|
||||||
<Badge variante="neutral" className="text-muted">
|
|
||||||
Beispieldaten
|
|
||||||
</Badge>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
aktionen={
|
|
||||||
<>
|
|
||||||
<Button icon={<PlugZap className="size-4" />}>Verbindung testen</Button>
|
|
||||||
<Button icon={<RotateCw className="size-4" />}>Rotieren</Button>
|
|
||||||
<Button variante="gefahr" icon={<Ban className="size-4" />}>
|
|
||||||
Widerrufen
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Karte as="div" innen="ohne">
|
|
||||||
<dl className="divide-border grid divide-y md:grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,1.1fr)] md:divide-x md:divide-y-0">
|
|
||||||
<InfoFeld titel="Fingerprint">
|
|
||||||
<MonoKopieren wert={s.fingerprint} beschriftung="Fingerprint" umbrechen className="-my-[11px]" />
|
|
||||||
</InfoFeld>
|
|
||||||
<InfoFeld titel="Erstellt">
|
|
||||||
<time dateTime={s.erstellt} className="font-mono text-[13px]">
|
|
||||||
{datumZeit(s.erstellt)}
|
|
||||||
</time>
|
|
||||||
<span className="text-muted block text-[13px]">von {s.erstelltVon}</span>
|
|
||||||
</InfoFeld>
|
|
||||||
<InfoFeld titel="Private Key">
|
|
||||||
<span className="inline-flex flex-wrap items-center gap-x-1.5 text-[14px]">
|
|
||||||
<Lock className="text-ok size-3.5" aria-hidden />
|
|
||||||
verschlüsselt gespeichert ·
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-accent -my-3 inline-flex min-h-11 items-center font-medium hover:underline"
|
|
||||||
>
|
|
||||||
anzeigen (Re-Auth)
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</InfoFeld>
|
|
||||||
</dl>
|
|
||||||
</Karte>
|
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
|
||||||
<AusrollKarte
|
|
||||||
titel="Private Key ausgerollt auf"
|
|
||||||
untertitel="Hosts, die sich mit diesem Schlüssel anmelden"
|
|
||||||
eintraege={privatAusgerollt}
|
|
||||||
/>
|
|
||||||
<AusrollKarte
|
|
||||||
titel="Public Key ausgerollt auf"
|
|
||||||
untertitel="Konten, die diesen Schlüssel annehmen"
|
|
||||||
eintraege={publicAusgerollt}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Karte
|
|
||||||
titel="Dry-Run-Vorschau"
|
|
||||||
untertitel={
|
|
||||||
<>
|
|
||||||
Abgleich für <span className="font-mono">backup@srv-pve02</span> – noch nichts geschrieben
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DiffBox datei={dryRunDatei} zeilen={dryRunDiff} />
|
|
||||||
<fieldset className="mt-5">
|
|
||||||
<legend className="text-muted mb-1 text-[13px] font-medium">Zusätzlich ausführen</legend>
|
|
||||||
<ul className="flex flex-col">
|
|
||||||
{zusatzaktionen.map((z) => (
|
|
||||||
<li key={z.id}>
|
|
||||||
<label className="flex min-h-11 cursor-pointer items-center gap-3 text-[14.5px]">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name={z.id}
|
|
||||||
defaultChecked={z.an}
|
|
||||||
className="accent-accent size-[18px] shrink-0"
|
|
||||||
/>
|
|
||||||
<span>{z.text}</span>
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</fieldset>
|
|
||||||
<div className="border-border mt-5 flex flex-wrap gap-2.5 border-t pt-5">
|
|
||||||
<Button variante="primaer" icon={<Check className="size-4" strokeWidth={2.4} />}>
|
|
||||||
Änderung anwenden
|
|
||||||
</Button>
|
|
||||||
<Button>Verwerfen</Button>
|
|
||||||
</div>
|
|
||||||
</Karte>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InfoFeld({ titel, children }: { titel: string; children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="min-w-0 px-5 py-4 sm:px-6">
|
|
||||||
<dt className="text-muted mb-1.5 text-[13px] font-medium">{titel}</dt>
|
|
||||||
<dd className="min-w-0">{children}</dd>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AusrollKarte({
|
|
||||||
titel,
|
|
||||||
untertitel,
|
|
||||||
eintraege,
|
|
||||||
}: {
|
|
||||||
titel: string;
|
|
||||||
untertitel: string;
|
|
||||||
eintraege: Ausrollung[];
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Karte
|
|
||||||
titel={titel}
|
|
||||||
untertitel={untertitel}
|
|
||||||
aktionen={
|
|
||||||
<Button klein icon={<Plus className="size-4" strokeWidth={2.4} />}>
|
|
||||||
Ausrollen
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ul className="flex flex-col gap-3">
|
|
||||||
{eintraege.map((e) => (
|
|
||||||
<li key={`${e.ziel}${e.pfad}`}>
|
|
||||||
<InnereBox className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-text font-mono text-[13.5px] font-medium break-all">{e.ziel}</p>
|
|
||||||
<p className="text-muted mt-1 font-mono text-[12.5px] break-all">{e.pfad}</p>
|
|
||||||
{e.zusatz && <p className="text-muted font-mono text-[12.5px] break-all">{e.zusatz}</p>}
|
|
||||||
</div>
|
|
||||||
<Statuspunkt status={e.status} text={e.statusText} className="shrink-0 sm:pt-0.5" />
|
|
||||||
</InnereBox>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</Karte>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { SchluesselNeu } from "@/components/schluessel/SchluesselNeu";
|
||||||
|
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { personenAuswahl, vorlagenAuswahl } from "@/server/queries/schluessel";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Schlüssel anlegen" };
|
||||||
|
|
||||||
|
type Suchparameter = Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
|
||||||
|
export default async function SchluesselNeuSeite({ searchParams }: { searchParams: Suchparameter }) {
|
||||||
|
await sitzungErforderlich("schluesselVerwalten", "/schluessel/neu");
|
||||||
|
const person = (await searchParams)["person"];
|
||||||
|
const [personen, vorlagen] = await Promise.all([personenAuswahl(), vorlagenAuswahl()]);
|
||||||
|
const vorgabePersonId =
|
||||||
|
typeof person === "string" && personen.some((p) => p.id === person) ? person : undefined;
|
||||||
|
return (
|
||||||
|
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
oben={
|
||||||
|
<Breadcrumb pfad={[{ titel: "Schlüssel", href: "/schluessel" }, { titel: "Neuer Schlüssel" }]} />
|
||||||
|
}
|
||||||
|
titel="Schlüssel anlegen"
|
||||||
|
untertitel="Neu erzeugen oder einen vorhandenen Schlüssel übernehmen."
|
||||||
|
/>
|
||||||
|
<SchluesselNeu
|
||||||
|
personen={personen.map((p) => ({ id: p.id, name: p.name, zusatz: p.organisation }))}
|
||||||
|
vorlagen={vorlagen.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
name: v.name,
|
||||||
|
zusatz: v.standardLaufzeitTage ? `${v.standardLaufzeitTage} Tage` : null,
|
||||||
|
}))}
|
||||||
|
{...(vorgabePersonId ? { vorgabePersonId } : {})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,27 +1,223 @@
|
|||||||
|
import { KeyRound, Lock, Plus, Search } from "lucide-react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
import { AblaufBadge, KategorieBadge, StatusBadge, TypBadge } from "@/components/schluessel/Anzeige";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Auswahlliste, Feld } from "@/components/ui/Feld";
|
||||||
|
import { Karte } from "@/components/ui/Karte";
|
||||||
|
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
|
||||||
|
import { datum } from "@/lib/format";
|
||||||
|
import { ablaufZustand, fingerprintKurz, KATEGORIE_TEXT, STATUS_TEXT } from "@/lib/schluessel";
|
||||||
|
import { darf } from "@/server/auth/rechte";
|
||||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { schluesselListe, type SchluesselListenEintrag } from "@/server/queries/schluessel";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Schlüssel" };
|
export const metadata: Metadata = { title: "Schlüssel" };
|
||||||
|
|
||||||
export default async function Schluessel() {
|
type Suchparameter = Promise<Record<string, string | string[] | undefined>>;
|
||||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
|
||||||
await sitzungErforderlich();
|
function einzeln(wert: string | string[] | undefined): string {
|
||||||
|
return (Array.isArray(wert) ? wert[0] : wert)?.trim() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function Verwendungen({ anzahl }: { anzahl: number }) {
|
||||||
|
if (anzahl === 0) return <span className="text-muted">keine</span>;
|
||||||
|
return <>{anzahl}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** In der Tabelle: Badge, wenn es drängt; sonst nur das Datum. */
|
||||||
|
function Ablauf({ ablaufAm, jetzt }: { ablaufAm: Date | null; jetzt: Date }) {
|
||||||
|
if (!ablaufAm) return <span className="text-muted">–</span>;
|
||||||
|
if (ablaufZustand(ablaufAm, jetzt).art === "spaeter") {
|
||||||
|
return (
|
||||||
|
<time dateTime={ablaufAm.toISOString()} className="text-muted">
|
||||||
|
{datum(ablaufAm)}
|
||||||
|
</time>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <AblaufBadge ablaufAm={ablaufAm} jetzt={jetzt} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PrivatHinweis({ s }: { s: SchluesselListenEintrag }) {
|
||||||
|
if (!s.privatGespeichert) return null;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<span title="Private Key verschlüsselt gespeichert" className="text-muted inline-flex">
|
||||||
<Platzhalterseite
|
<Lock className="size-3.5" aria-hidden />
|
||||||
|
<span className="sr-only">Private Key gespeichert</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schlüsselliste mit Filter (#012). Filter per GET, damit Links teilbar sind. */
|
||||||
|
export default async function Schluessel({ searchParams }: { searchParams: Suchparameter }) {
|
||||||
|
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||||
|
const { benutzer } = await sitzungErforderlich("lesen", "/schluessel");
|
||||||
|
const parameter = await searchParams;
|
||||||
|
const q = einzeln(parameter["q"]);
|
||||||
|
const kategorie = einzeln(parameter["kategorie"]);
|
||||||
|
const status = einzeln(parameter["status"]);
|
||||||
|
const liste = await schluesselListe({ q, kategorie, status });
|
||||||
|
const verwalten = darf(benutzer.rolle, "schluesselVerwalten");
|
||||||
|
const gefiltert = q !== "" || kategorie !== "" || status !== "";
|
||||||
|
const jetzt = new Date();
|
||||||
|
|
||||||
|
const anlegen = verwalten && (
|
||||||
|
<Button href="/schluessel/neu" variante="primaer" icon={<Plus className="size-4" strokeWidth={2.4} />}>
|
||||||
|
Schlüssel anlegen
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
titel="Schlüssel"
|
titel="Schlüssel"
|
||||||
untertitel="SSH-Schlüssel erzeugen, importieren und ausrollen"
|
untertitel={
|
||||||
phase={2}
|
gefiltert
|
||||||
issue="#012"
|
? `${liste.length} Treffer`
|
||||||
|
: `${liste.length} ${liste.length === 1 ? "verwalteter Schlüssel" : "verwaltete Schlüssel"}`
|
||||||
|
}
|
||||||
|
aktionen={anlegen}
|
||||||
/>
|
/>
|
||||||
<p className="text-muted text-[14px]">
|
|
||||||
Prototyp ansehen:{" "}
|
<form
|
||||||
<Link href="/schluessel/beispiel" className="text-accent font-medium hover:underline">
|
method="get"
|
||||||
Schlüsseldetail mit Beispieldaten
|
role="search"
|
||||||
</Link>
|
className="grid items-end gap-3 sm:grid-cols-2 lg:grid-cols-[minmax(0,1fr)_11rem_11rem_auto]"
|
||||||
</p>
|
>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Suche"
|
||||||
|
name="q"
|
||||||
|
type="search"
|
||||||
|
defaultValue={q}
|
||||||
|
placeholder="Zweck, Fingerprint, Tag, Person"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
className="sm:col-span-2 lg:col-span-1"
|
||||||
|
/>
|
||||||
|
<Auswahlliste beschriftung="Kategorie" name="kategorie" defaultValue={kategorie}>
|
||||||
|
<option value="">Alle Kategorien</option>
|
||||||
|
{Object.entries(KATEGORIE_TEXT).map(([wert, text]) => (
|
||||||
|
<option key={wert} value={wert}>
|
||||||
|
{text}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<Auswahlliste beschriftung="Status" name="status" defaultValue={status}>
|
||||||
|
<option value="">Alle</option>
|
||||||
|
{Object.entries(STATUS_TEXT).map(([wert, text]) => (
|
||||||
|
<option key={wert} value={wert}>
|
||||||
|
{text}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<div className="flex gap-2.5 sm:col-span-2 lg:col-span-1">
|
||||||
|
<Button type="submit" icon={<Search className="size-4" />} className="flex-1 lg:flex-none">
|
||||||
|
Filtern
|
||||||
|
</Button>
|
||||||
|
{gefiltert && (
|
||||||
|
<Button href="/schluessel" className="flex-1 lg:flex-none">
|
||||||
|
Zurücksetzen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{liste.length === 0 ? (
|
||||||
|
gefiltert ? (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<Search className="size-5" />}
|
||||||
|
titel="Keine Treffer"
|
||||||
|
text="Kein Schlüssel passt zu Suche und Filter."
|
||||||
|
aktion={<Button href="/schluessel">Filter zurücksetzen</Button>}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<KeyRound className="size-5" />}
|
||||||
|
titel="Noch keine Schlüssel"
|
||||||
|
text="Erzeugen Sie einen Schlüssel oder importieren Sie einen vorhandenen Public Key."
|
||||||
|
aktion={anlegen}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Karte>
|
||||||
|
{/* Unter md eine Liste statt der Tabelle: sechs Spalten passen nicht auf 390 px. */}
|
||||||
|
<ul className="flex flex-col md:hidden">
|
||||||
|
{liste.map((s) => (
|
||||||
|
<li key={s.id} className="border-border border-b last:border-b-0">
|
||||||
|
<Link
|
||||||
|
href={`/schluessel/${s.id}`}
|
||||||
|
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 flex-col gap-2 px-2 py-3 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-text flex min-w-0 items-center gap-2 text-[14.5px] font-medium">
|
||||||
|
<span className="truncate">{s.name}</span>
|
||||||
|
<PrivatHinweis s={s} />
|
||||||
|
</span>
|
||||||
|
<span className="text-muted block truncate font-mono text-[12.5px]">
|
||||||
|
{fingerprintKurz(s.fingerprint)}
|
||||||
|
</span>
|
||||||
|
<span className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<TypBadge typ={s.typ} publicKey={s.publicKey} />
|
||||||
|
<KategorieBadge kategorie={s.kategorie} />
|
||||||
|
<StatusBadge status={s.status} />
|
||||||
|
<AblaufBadge ablaufAm={s.ablaufAm} jetzt={jetzt} />
|
||||||
|
</span>
|
||||||
|
<span className="text-muted text-[13px]">
|
||||||
|
Verwendungen: <Verwendungen anzahl={s.verwendungen} />
|
||||||
|
{s.person && <> · {s.person.name}</>}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<Tabelle
|
||||||
|
className="hidden md:block"
|
||||||
|
beschriftung="Schlüssel"
|
||||||
|
spalten={[
|
||||||
|
{ titel: "Name" },
|
||||||
|
{ titel: "Typ" },
|
||||||
|
{ titel: "Kategorie" },
|
||||||
|
{ titel: "Fingerprint" },
|
||||||
|
{ titel: "Ablauf" },
|
||||||
|
{ titel: "Verwendungen" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{liste.map((s) => (
|
||||||
|
<TabellenZeile key={s.id}>
|
||||||
|
<TabellenZelle>
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/schluessel/${s.id}`}
|
||||||
|
className="text-text hover:text-accent -my-2 inline-flex min-h-11 items-center text-[14px] font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{s.name}
|
||||||
|
</Link>
|
||||||
|
<PrivatHinweis s={s} />
|
||||||
|
<StatusBadge status={s.status} />
|
||||||
|
</span>
|
||||||
|
{s.person && <span className="text-muted block text-[12.5px]">{s.person.name}</span>}
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle className="whitespace-nowrap">
|
||||||
|
<TypBadge typ={s.typ} publicKey={s.publicKey} />
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle className="whitespace-nowrap">
|
||||||
|
<KategorieBadge kategorie={s.kategorie} />
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle mono className="text-muted whitespace-nowrap">
|
||||||
|
<span title={s.fingerprint}>{fingerprintKurz(s.fingerprint)}</span>
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle className="whitespace-nowrap">
|
||||||
|
<Ablauf ablaufAm={s.ablaufAm} jetzt={jetzt} />
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle className="whitespace-nowrap">
|
||||||
|
<Verwendungen anzahl={s.verwendungen} />
|
||||||
|
</TabellenZelle>
|
||||||
|
</TabellenZeile>
|
||||||
|
))}
|
||||||
|
</Tabelle>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { publicKeyExport } from "@/server/queries/schluessel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public Key als Datei `<slug>.pub` (#012). Ein Public Key ist kein Geheimnis – jede
|
||||||
|
* angemeldete Rolle darf ihn holen. Der Slug passt per DB-CHECK in einen Dateinamen.
|
||||||
|
*/
|
||||||
|
export async function GET(_anfrage: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
await rechtErforderlich("lesen");
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof RechteFehler) return NextResponse.json({ fehler: fehler.message }, { status: 403 });
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
const schluessel = await publicKeyExport((await params).id);
|
||||||
|
if (!schluessel) return NextResponse.json({ fehler: "Unbekannter Schlüssel." }, { status: 404 });
|
||||||
|
return new NextResponse(`${schluessel.publicKey}\n`, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/plain; charset=utf-8",
|
||||||
|
"Content-Disposition": `attachment; filename="${schluessel.slug}.pub"`,
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
/**
|
|
||||||
* BEISPIELDATEN für den Prototyp des Schlüsseldetails (#002) – kein echter Schlüssel.
|
|
||||||
* Ersetzt durch das Lesemodell aus #012/#016/#017.
|
|
||||||
*/
|
|
||||||
import type { HostStatus } from "@/components/ui/Statuspunkt";
|
|
||||||
import type { DiffZeile } from "@/components/ui/DiffBox";
|
|
||||||
|
|
||||||
export const beispielSchluessel = {
|
|
||||||
zweck: "Backup srv-backup01 → srv-pve01",
|
|
||||||
typ: "ed25519",
|
|
||||||
kategorie: "Dienst",
|
|
||||||
vorlage: "Backup-Zugang",
|
|
||||||
ablauf: "2026-10-30T10:00:00Z",
|
|
||||||
fingerprint: "SHA256:q3vN8xP0b4Tz7KfL2mWcR9yHdE1sJ6uAoGi5VnBQe0k",
|
|
||||||
erstellt: "2026-04-30T08:12:00Z",
|
|
||||||
erstelltVon: "Florian Wilde",
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Ausrollung = {
|
|
||||||
ziel: string;
|
|
||||||
pfad: string;
|
|
||||||
zusatz?: string;
|
|
||||||
status: HostStatus;
|
|
||||||
statusText?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const privatAusgerollt: Ausrollung[] = [
|
|
||||||
{
|
|
||||||
ziel: "backup@srv-backup01",
|
|
||||||
pfad: "~/.ssh/id_ed25519_pve01",
|
|
||||||
zusatz: "config: Host srv-pve01",
|
|
||||||
status: "synchron",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const publicAusgerollt: Ausrollung[] = [
|
|
||||||
{
|
|
||||||
ziel: "backup@srv-pve01",
|
|
||||||
pfad: "~/.ssh/authorized_keys",
|
|
||||||
zusatz: 'from="10.20.0.15",no-pty,no-agent-forwarding',
|
|
||||||
status: "synchron",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ziel: "backup@srv-pve02",
|
|
||||||
pfad: "~/.ssh/authorized_keys",
|
|
||||||
zusatz: 'from="10.20.0.15",no-pty',
|
|
||||||
status: "drift",
|
|
||||||
statusText: "Optionen geändert",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const dryRunDatei = "backup@srv-pve02:~/.ssh/authorized_keys";
|
|
||||||
|
|
||||||
export const dryRunDiff: DiffZeile[] = [
|
|
||||||
{ art: "kontext", text: "# --- sshkv verwaltet: Anfang ---" },
|
|
||||||
{
|
|
||||||
art: "kontext",
|
|
||||||
text: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMvq2Lr7… monitoring@zabbix",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
art: "entfernt",
|
|
||||||
text: 'from="10.20.0.15",no-pty ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFt0b8c1… backup@srv-backup01',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
art: "hinzu",
|
|
||||||
text: 'from="10.20.0.15",no-pty,no-agent-forwarding ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFt0b8c1… backup@srv-backup01',
|
|
||||||
},
|
|
||||||
{ art: "kontext", text: "# --- sshkv verwaltet: Ende ---" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const zusatzaktionen = [
|
|
||||||
{ id: "backup", text: "Backup der Datei anlegen (authorized_keys.sshkv-bak)", an: true },
|
|
||||||
{ id: "config", text: "~/.ssh/config-Eintrag auf srv-backup01 anlegen", an: true },
|
|
||||||
{ id: "knownhosts", text: "known_hosts auf srv-backup01 aktualisieren", an: false },
|
|
||||||
];
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { datum } from "@/lib/format";
|
||||||
|
import {
|
||||||
|
ablaufZustand,
|
||||||
|
KATEGORIE_TEXT,
|
||||||
|
STATUS_TEXT,
|
||||||
|
typAnzeige,
|
||||||
|
type Kategorie,
|
||||||
|
type Status,
|
||||||
|
} from "@/lib/schluessel";
|
||||||
|
|
||||||
|
/** Kleine Anzeigebausteine fuer Schluessel (#012) – reine Darstellung. */
|
||||||
|
|
||||||
|
export function TypBadge({ typ, publicKey }: { typ: "ED25519" | "RSA4096" | "ANDERER"; publicKey: string }) {
|
||||||
|
return <Badge mono>{typAnzeige(typ, publicKey)}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KategorieBadge({ kategorie }: { kategorie: Kategorie }) {
|
||||||
|
return <Badge>{KATEGORIE_TEXT[kategorie]}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AKTIV zeigen wir nicht eigens – nur Abweichungen vom Normalfall. */
|
||||||
|
export function StatusBadge({ status }: { status: Status }) {
|
||||||
|
if (status === "AKTIV") return null;
|
||||||
|
return <Badge variante={status === "ROTIERT" ? "neutral" : "gefahr"}>{STATUS_TEXT[status]}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ablauf: in Messing, sobald er in hoechstens 30 Tagen ansteht (Design #002), in Koralle,
|
||||||
|
* wenn er vorbei ist. `lang` zeigt zusaetzlich das Datum (Detailseite).
|
||||||
|
*/
|
||||||
|
export function AblaufBadge({
|
||||||
|
ablaufAm,
|
||||||
|
jetzt,
|
||||||
|
lang = false,
|
||||||
|
}: {
|
||||||
|
ablaufAm: Date | null;
|
||||||
|
jetzt: Date;
|
||||||
|
lang?: boolean;
|
||||||
|
}) {
|
||||||
|
const zustand = ablaufZustand(ablaufAm, jetzt);
|
||||||
|
if (zustand.art === "keiner" || !ablaufAm) return null;
|
||||||
|
const zeit = <time dateTime={ablaufAm.toISOString()}>{datum(ablaufAm)}</time>;
|
||||||
|
if (zustand.art === "abgelaufen") {
|
||||||
|
return <Badge variante="gefahr">{lang ? <span>abgelaufen am {zeit}</span> : "abgelaufen"}</Badge>;
|
||||||
|
}
|
||||||
|
const in_ = zustand.tage === 0 ? "heute" : zustand.tage === 1 ? "morgen" : `in ${zustand.tage} Tagen`;
|
||||||
|
if (zustand.art === "bald") {
|
||||||
|
return (
|
||||||
|
<Badge variante="messing">
|
||||||
|
{lang ? (
|
||||||
|
<span>
|
||||||
|
läuft ab am {zeit} · {in_}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>läuft ab {in_}</span>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return lang ? (
|
||||||
|
<Badge variante="neutral" className="text-muted">
|
||||||
|
<span>läuft ab am {zeit}</span>
|
||||||
|
</Badge>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Auswahlliste, Button, Feld, Formularfehler, Karte } from "@/components/ui";
|
||||||
|
import { useFormularAktion } from "@/components/hosts/useFormularAktion";
|
||||||
|
import { schluesselBearbeitenAktion } from "@/server/actions/schluessel";
|
||||||
|
import type { Auswahl } from "./SchluesselNeu";
|
||||||
|
|
||||||
|
export type SchluesselFormularWerte = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
tags: string[];
|
||||||
|
/** JJJJ-MM-TT oder leer */
|
||||||
|
ablaufAm: string;
|
||||||
|
personId: string;
|
||||||
|
vorlageId: string;
|
||||||
|
status: "AKTIV" | "ROTIERT" | "WIDERRUFEN" | "ABGELAUFEN";
|
||||||
|
statusAenderbar: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadaten bearbeiten (#012). Slug, Typ und Kommentar bleiben – sie stehen schon in
|
||||||
|
* Dateinamen und authorized_keys-Zeilen auf den Hosts.
|
||||||
|
*/
|
||||||
|
export function SchluesselBearbeiten({
|
||||||
|
werte,
|
||||||
|
personen,
|
||||||
|
vorlagen,
|
||||||
|
}: {
|
||||||
|
werte: SchluesselFormularWerte;
|
||||||
|
personen: Auswahl[];
|
||||||
|
vorlagen: Auswahl[];
|
||||||
|
}) {
|
||||||
|
const { zustand, absenden, laeuft } = useFormularAktion(schluesselBearbeitenAktion);
|
||||||
|
const f = zustand.feldfehler ?? {};
|
||||||
|
return (
|
||||||
|
<form onSubmit={absenden} noValidate className="flex flex-col gap-6">
|
||||||
|
<input type="hidden" name="id" value={werte.id} />
|
||||||
|
<Formularfehler text={zustand.fehler} />
|
||||||
|
<Karte titel="Metadaten">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld
|
||||||
|
beschriftung="Zweck"
|
||||||
|
name="name"
|
||||||
|
defaultValue={werte.name}
|
||||||
|
required
|
||||||
|
maxLength={120}
|
||||||
|
fehler={f["name"]}
|
||||||
|
className="sm:col-span-2"
|
||||||
|
/>
|
||||||
|
<Auswahlliste beschriftung="Person" name="personId" defaultValue={werte.personId}>
|
||||||
|
<option value="">keine</option>
|
||||||
|
{personen.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.zusatz ? `${p.name} (${p.zusatz})` : p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<Auswahlliste beschriftung="Vorlage" name="vorlageId" defaultValue={werte.vorlageId}>
|
||||||
|
<option value="">keine</option>
|
||||||
|
{vorlagen.map((v) => (
|
||||||
|
<option key={v.id} value={v.id}>
|
||||||
|
{v.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Ablaufdatum"
|
||||||
|
name="ablaufAm"
|
||||||
|
type="date"
|
||||||
|
defaultValue={werte.ablaufAm}
|
||||||
|
fehler={f["ablaufAm"]}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Tags"
|
||||||
|
name="tags"
|
||||||
|
mono
|
||||||
|
defaultValue={werte.tags.join(", ")}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
hinweis="Durch Komma oder Leerzeichen getrennt, max. 20."
|
||||||
|
fehler={f["tags"]}
|
||||||
|
/>
|
||||||
|
{werte.statusAenderbar && (
|
||||||
|
<Auswahlliste beschriftung="Status" name="status" defaultValue={werte.status}>
|
||||||
|
<option value="AKTIV">aktiv</option>
|
||||||
|
<option value="ABGELAUFEN">abgelaufen</option>
|
||||||
|
</Auswahlliste>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
<div className="flex flex-wrap gap-2.5">
|
||||||
|
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||||||
|
{laeuft ? "Speichere …" : "Änderungen speichern"}
|
||||||
|
</Button>
|
||||||
|
<Button href={`/schluessel/${werte.id}`}>Abbrechen</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Download, KeyRound, Sparkles } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState, useTransition, type FormEvent, type ReactNode } from "react";
|
||||||
|
import { clsx } from "clsx";
|
||||||
|
import {
|
||||||
|
Ankreuzfeld,
|
||||||
|
Auswahlliste,
|
||||||
|
Button,
|
||||||
|
Feld,
|
||||||
|
Feldgruppe,
|
||||||
|
Formularfehler,
|
||||||
|
Karte,
|
||||||
|
Textbereich,
|
||||||
|
} from "@/components/ui";
|
||||||
|
import { istLaufend, useVorgang } from "@/components/vorgang/useVorgang";
|
||||||
|
import {
|
||||||
|
paarImportierenAktion,
|
||||||
|
publicKeyImportierenAktion,
|
||||||
|
schluesselErzeugenAktion,
|
||||||
|
type SchluesselAntwort,
|
||||||
|
} from "@/server/actions/schluessel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neuer Schluessel (#012): erzeugen oder importieren (nur Public Key oder Paar).
|
||||||
|
*
|
||||||
|
* Erzeugen und Paar-Import laufen als Vorgang im Worker; das Formular verfolgt ihn und
|
||||||
|
* springt nach Erfolg zum Schluessel. Passphrase und Private Key stehen nur so lange im
|
||||||
|
* Formular, bis sie abgeschickt sind.
|
||||||
|
*/
|
||||||
|
type Modus = "erzeugen" | "public" | "paar";
|
||||||
|
|
||||||
|
export type Auswahl = { id: string; name: string; zusatz?: string | null };
|
||||||
|
|
||||||
|
const HINWEIS_PASSPHRASE = "Für automatische Anmeldungen (Host-zu-Host) ungeeignet.";
|
||||||
|
|
||||||
|
function Umschalter({
|
||||||
|
wert,
|
||||||
|
setzen,
|
||||||
|
optionen,
|
||||||
|
beschriftung,
|
||||||
|
}: {
|
||||||
|
wert: string;
|
||||||
|
setzen: (wert: string) => void;
|
||||||
|
optionen: { wert: string; text: string; icon?: ReactNode }[];
|
||||||
|
beschriftung: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label={beschriftung}
|
||||||
|
className="border-border-strong bg-sidebar rounded-box inline-flex w-full max-w-md gap-1 border p-1 sm:w-auto sm:self-start"
|
||||||
|
>
|
||||||
|
{optionen.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.wert}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={wert === o.wert}
|
||||||
|
onClick={() => setzen(o.wert)}
|
||||||
|
className={clsx(
|
||||||
|
"rounded-feld inline-flex min-h-10 flex-1 items-center justify-center gap-2 px-4 text-[14px] font-medium transition-colors sm:flex-none",
|
||||||
|
wert === o.wert ? "bg-raised text-text" : "text-muted hover:text-text",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{o.icon}
|
||||||
|
{o.text}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SchluesselNeu({
|
||||||
|
personen,
|
||||||
|
vorlagen,
|
||||||
|
vorgabePersonId,
|
||||||
|
}: {
|
||||||
|
personen: Auswahl[];
|
||||||
|
vorlagen: Auswahl[];
|
||||||
|
vorgabePersonId?: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [art, setArt] = useState<"erzeugen" | "importieren">("erzeugen");
|
||||||
|
const [importArt, setImportArt] = useState<"public" | "paar">("public");
|
||||||
|
const modus: Modus = art === "erzeugen" ? "erzeugen" : importArt;
|
||||||
|
|
||||||
|
const [antwort, setAntwort] = useState<SchluesselAntwort>({});
|
||||||
|
const [sendet, senden] = useTransition();
|
||||||
|
const { stand, abfrageFehler } = useVorgang(antwort.vorgangId);
|
||||||
|
|
||||||
|
// Geheimnisse kontrolliert, damit sie nach dem Absenden sofort aus dem Formular verschwinden.
|
||||||
|
const [privateKey, setPrivateKey] = useState("");
|
||||||
|
const [passphrase, setPassphrase] = useState("");
|
||||||
|
const [wiederholung, setWiederholung] = useState("");
|
||||||
|
|
||||||
|
const ergebnisId =
|
||||||
|
stand?.status === "ERLEDIGT" && stand.ergebnis && typeof stand.ergebnis === "object"
|
||||||
|
? (stand.ergebnis as { schluesselId?: unknown }).schluesselId
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof ergebnisId === "string") router.push(`/schluessel/${ergebnisId}`);
|
||||||
|
}, [ergebnisId, router]);
|
||||||
|
|
||||||
|
const vorgangLaeuft = antwort.vorgangId !== undefined && istLaufend(stand) && !abfrageFehler;
|
||||||
|
const beschaeftigt = sendet || vorgangLaeuft || typeof ergebnisId === "string";
|
||||||
|
const f = antwort.feldfehler ?? {};
|
||||||
|
const fehler =
|
||||||
|
antwort.fehler ??
|
||||||
|
abfrageFehler ??
|
||||||
|
(stand?.status === "FEHLGESCHLAGEN" ? (stand.fehler ?? undefined) : undefined);
|
||||||
|
|
||||||
|
function absenden(ereignis: FormEvent<HTMLFormElement>) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
const daten = new FormData(ereignis.currentTarget);
|
||||||
|
const aktion =
|
||||||
|
modus === "erzeugen"
|
||||||
|
? schluesselErzeugenAktion
|
||||||
|
: modus === "paar"
|
||||||
|
? paarImportierenAktion
|
||||||
|
: publicKeyImportierenAktion;
|
||||||
|
senden(async () => {
|
||||||
|
setAntwort({});
|
||||||
|
const neu = await aktion({}, daten);
|
||||||
|
if (neu.vorgangId || neu.schluesselId) {
|
||||||
|
setPrivateKey("");
|
||||||
|
setPassphrase("");
|
||||||
|
setWiederholung("");
|
||||||
|
}
|
||||||
|
if (neu.schluesselId) router.push(`/schluessel/${neu.schluesselId}`);
|
||||||
|
setAntwort(neu);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const knopfText = beschaeftigt
|
||||||
|
? modus === "erzeugen"
|
||||||
|
? "Erzeuge …"
|
||||||
|
: "Importiere …"
|
||||||
|
: modus === "erzeugen"
|
||||||
|
? "Schlüssel erzeugen"
|
||||||
|
: "Schlüssel importieren";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={absenden} noValidate className="flex flex-col gap-6" autoComplete="off">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Umschalter
|
||||||
|
beschriftung="Art"
|
||||||
|
wert={art}
|
||||||
|
setzen={(w) => {
|
||||||
|
setArt(w as typeof art);
|
||||||
|
setAntwort({});
|
||||||
|
}}
|
||||||
|
optionen={[
|
||||||
|
{ wert: "erzeugen", text: "Erzeugen", icon: <Sparkles className="size-4" aria-hidden /> },
|
||||||
|
{ wert: "importieren", text: "Importieren", icon: <Download className="size-4" aria-hidden /> },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{art === "importieren" && (
|
||||||
|
<Umschalter
|
||||||
|
beschriftung="Was importieren?"
|
||||||
|
wert={importArt}
|
||||||
|
setzen={(w) => {
|
||||||
|
setImportArt(w as typeof importArt);
|
||||||
|
setAntwort({});
|
||||||
|
}}
|
||||||
|
optionen={[
|
||||||
|
{ wert: "public", text: "Nur Public Key" },
|
||||||
|
{ wert: "paar", text: "Schlüsselpaar" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Formularfehler text={fehler} />
|
||||||
|
|
||||||
|
<Karte titel="Zweck">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld
|
||||||
|
beschriftung="Zweck"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
maxLength={120}
|
||||||
|
placeholder="Backup srv-backup01 → srv-pve01"
|
||||||
|
fehler={f["name"]}
|
||||||
|
className="sm:col-span-2"
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Kurzname (optional)"
|
||||||
|
name="slug"
|
||||||
|
mono
|
||||||
|
maxLength={64}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="wird aus dem Zweck gebildet"
|
||||||
|
hinweis="Für Dateinamen und Kommentare: a–z, 0–9, Bindestrich."
|
||||||
|
fehler={f["slug"]}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Kommentar (optional)"
|
||||||
|
name="kommentar"
|
||||||
|
mono
|
||||||
|
maxLength={200}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder={modus === "erzeugen" ? "sshkv:<kurzname>" : "aus dem Schlüssel"}
|
||||||
|
hinweis="Steht hinter dem Public Key in authorized_keys."
|
||||||
|
fehler={f["kommentar"]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
{modus === "erzeugen" && (
|
||||||
|
<Karte
|
||||||
|
titel="Schlüssel"
|
||||||
|
untertitel="sshkv erzeugt das Paar im Worker und speichert den Private Key verschlüsselt"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<Feldgruppe legende="Typ" fehler={f["typ"]}>
|
||||||
|
<div className="grid gap-x-4 sm:grid-cols-2">
|
||||||
|
<Ankreuzfeld
|
||||||
|
type="radio"
|
||||||
|
name="typ"
|
||||||
|
value="ED25519"
|
||||||
|
defaultChecked
|
||||||
|
beschriftung={<span className="font-mono">ed25519</span>}
|
||||||
|
erklaerung="Empfohlen – kurz, schnell, sicher."
|
||||||
|
/>
|
||||||
|
<Ankreuzfeld
|
||||||
|
type="radio"
|
||||||
|
name="typ"
|
||||||
|
value="RSA4096"
|
||||||
|
beschriftung={<span className="font-mono">rsa-4096</span>}
|
||||||
|
erklaerung="Nur für alte Gegenstellen ohne ed25519."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Feldgruppe>
|
||||||
|
<p className="text-muted text-[13.5px]">
|
||||||
|
Kategorie: <span className="text-text">Dienst</span> – Personen hinterlegen ihre Public Keys
|
||||||
|
selbst unter „Meine Schlüssel“, deren Private Keys speichert sshkv nie.
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld
|
||||||
|
beschriftung="Passphrase (optional)"
|
||||||
|
name="passphrase"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={passphrase}
|
||||||
|
onChange={(e) => setPassphrase(e.target.value)}
|
||||||
|
hinweis={`Wird nicht gespeichert. ${HINWEIS_PASSPHRASE}`}
|
||||||
|
fehler={f["passphrase"]}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Passphrase wiederholen"
|
||||||
|
name="passphraseWiederholung"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={wiederholung}
|
||||||
|
onChange={(e) => setWiederholung(e.target.value)}
|
||||||
|
disabled={!passphrase}
|
||||||
|
fehler={f["passphraseWiederholung"]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{modus === "public" && (
|
||||||
|
<Karte titel="Public Key" untertitel="Eine Zeile im OpenSSH-Format, z. B. aus id_ed25519.pub">
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<Textbereich
|
||||||
|
beschriftung="Public Key"
|
||||||
|
name="publicKey"
|
||||||
|
required
|
||||||
|
rows={4}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA… name@rechner"
|
||||||
|
fehler={f["publicKey"]}
|
||||||
|
className="[&_textarea]:font-mono [&_textarea]:text-[13px]"
|
||||||
|
/>
|
||||||
|
<Feldgruppe legende="Kategorie" fehler={f["kategorie"]}>
|
||||||
|
<div className="grid gap-x-4 sm:grid-cols-2">
|
||||||
|
<Ankreuzfeld
|
||||||
|
type="radio"
|
||||||
|
name="kategorie"
|
||||||
|
value="DIENST"
|
||||||
|
defaultChecked={!vorgabePersonId}
|
||||||
|
beschriftung="Dienst"
|
||||||
|
erklaerung="Ein Programm oder Host meldet sich damit an."
|
||||||
|
/>
|
||||||
|
<Ankreuzfeld
|
||||||
|
type="radio"
|
||||||
|
name="kategorie"
|
||||||
|
value="PERSON"
|
||||||
|
defaultChecked={Boolean(vorgabePersonId)}
|
||||||
|
beschriftung="Person"
|
||||||
|
erklaerung="Ein Mensch meldet sich damit an."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Feldgruppe>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{modus === "paar" && (
|
||||||
|
<Karte
|
||||||
|
titel="Schlüsselpaar"
|
||||||
|
untertitel="Private Key im OpenSSH-Format – sshkv prüft ihn im Worker und speichert ihn verschlüsselt"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<Textbereich
|
||||||
|
beschriftung="Private Key"
|
||||||
|
name="privateKey"
|
||||||
|
required
|
||||||
|
rows={7}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
value={privateKey}
|
||||||
|
onChange={(e) => setPrivateKey(e.target.value)}
|
||||||
|
placeholder={"-----BEGIN OPENSSH PRIVATE KEY-----\n…\n-----END OPENSSH PRIVATE KEY-----"}
|
||||||
|
fehler={f["privateKey"]}
|
||||||
|
className="[&_textarea]:font-mono [&_textarea]:text-[12.5px]"
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Passphrase (nur falls der Key eine hat)"
|
||||||
|
name="passphrase"
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
value={passphrase}
|
||||||
|
onChange={(e) => setPassphrase(e.target.value)}
|
||||||
|
hinweis={`Nur zur Prüfung, wird verworfen. Der Key bleibt passphrasengeschützt gespeichert – ${HINWEIS_PASSPHRASE.toLowerCase()}`}
|
||||||
|
fehler={f["passphrase"]}
|
||||||
|
className="sm:max-w-md"
|
||||||
|
/>
|
||||||
|
<p className="text-muted text-[13.5px]">
|
||||||
|
Kategorie: <span className="text-text">Dienst</span> – Private Keys von Personen speichert sshkv
|
||||||
|
nie.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Karte titel="Einordnung">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Auswahlliste
|
||||||
|
beschriftung={modus === "public" ? "Person (optional)" : "Verantwortliche Person (optional)"}
|
||||||
|
name="personId"
|
||||||
|
defaultValue={vorgabePersonId ?? ""}
|
||||||
|
>
|
||||||
|
<option value="">keine</option>
|
||||||
|
{personen.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.zusatz ? `${p.name} (${p.zusatz})` : p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<Auswahlliste beschriftung="Vorlage (optional)" name="vorlageId" defaultValue="">
|
||||||
|
<option value="">keine</option>
|
||||||
|
{vorlagen.map((v) => (
|
||||||
|
<option key={v.id} value={v.id}>
|
||||||
|
{v.zusatz ? `${v.name} (${v.zusatz})` : v.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Ablaufdatum (optional)"
|
||||||
|
name="ablaufAm"
|
||||||
|
type="date"
|
||||||
|
hinweis="Ohne Angabe gilt die Laufzeit der Vorlage, sonst keine."
|
||||||
|
fehler={f["ablaufAm"]}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Tags"
|
||||||
|
name="tags"
|
||||||
|
mono
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="backup, pve"
|
||||||
|
hinweis="Durch Komma oder Leerzeichen getrennt, max. 20."
|
||||||
|
fehler={f["tags"]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2.5">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variante="primaer"
|
||||||
|
disabled={beschaeftigt}
|
||||||
|
icon={<KeyRound className="size-4" />}
|
||||||
|
>
|
||||||
|
{knopfText}
|
||||||
|
</Button>
|
||||||
|
<Button href="/schluessel">Abbrechen</Button>
|
||||||
|
<p role="status" className="text-muted text-[13px]">
|
||||||
|
{vorgangLaeuft && "Der Worker arbeitet …"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { clsx } from "clsx";
|
||||||
import { Check, Copy } from "lucide-react";
|
import { Check, Copy } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
@@ -8,10 +9,13 @@ export function MonoBlock({
|
|||||||
wert,
|
wert,
|
||||||
beschriftung,
|
beschriftung,
|
||||||
hoehe,
|
hoehe,
|
||||||
|
umbrechen = false,
|
||||||
}: {
|
}: {
|
||||||
wert: string;
|
wert: string;
|
||||||
beschriftung: string;
|
beschriftung: string;
|
||||||
hoehe?: string;
|
hoehe?: string;
|
||||||
|
/** Lange Einzeiler (Public Keys) umbrechen statt seitlich scrollen (#012). */
|
||||||
|
umbrechen?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [kopiert, setKopiert] = useState(false);
|
const [kopiert, setKopiert] = useState(false);
|
||||||
return (
|
return (
|
||||||
@@ -33,7 +37,10 @@ export function MonoBlock({
|
|||||||
{kopiert ? <Check className="text-ok size-4" aria-hidden /> : <Copy className="size-4" aria-hidden />}
|
{kopiert ? <Check className="text-ok size-4" aria-hidden /> : <Copy className="size-4" aria-hidden />}
|
||||||
</button>
|
</button>
|
||||||
<pre
|
<pre
|
||||||
className="text-text overflow-auto p-4 pr-16 font-mono text-[12.5px] leading-[1.55]"
|
className={clsx(
|
||||||
|
"text-text overflow-auto p-4 pr-16 font-mono text-[12.5px] leading-[1.55]",
|
||||||
|
umbrechen && "break-all whitespace-pre-wrap",
|
||||||
|
)}
|
||||||
style={hoehe ? { maxHeight: hoehe } : undefined}
|
style={hoehe ? { maxHeight: hoehe } : undefined}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={beschriftung}
|
aria-label={beschriftung}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verfolgt einen Vorgang bis zum Ende (#012) – wie VorgangsKnopf, aber fuer Formulare, die
|
||||||
|
* nach dem Ende etwas mit dem Ergebnis tun (z. B. zum neuen Schluessel springen).
|
||||||
|
*/
|
||||||
|
export type VorgangsStand = {
|
||||||
|
readonly status: string;
|
||||||
|
readonly fehler: string | null;
|
||||||
|
readonly ergebnis: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LAUFEND = new Set(["WARTEND", "LAEUFT"]);
|
||||||
|
|
||||||
|
export function istLaufend(stand: VorgangsStand | undefined): boolean {
|
||||||
|
return stand === undefined || LAUFEND.has(stand.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Gemerkt = { readonly id: string; readonly stand?: VorgangsStand; readonly fehler?: string };
|
||||||
|
|
||||||
|
export function useVorgang(vorgangId: string | undefined): {
|
||||||
|
stand: VorgangsStand | undefined;
|
||||||
|
abfrageFehler: string | undefined;
|
||||||
|
} {
|
||||||
|
// Nach der ID gemerkt: ein neuer Vorgang zeigt so nie den Stand des vorigen.
|
||||||
|
const [gemerkt, setGemerkt] = useState<Gemerkt>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!vorgangId) return;
|
||||||
|
let abbrechen = false;
|
||||||
|
const abfragen = async () => {
|
||||||
|
while (!abbrechen) {
|
||||||
|
const antwort = await fetch(`/api/vorgaenge/${vorgangId}`, { cache: "no-store" });
|
||||||
|
if (abbrechen) return;
|
||||||
|
if (!antwort.ok) {
|
||||||
|
setGemerkt({ id: vorgangId, fehler: "Status des Vorgangs nicht abrufbar." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const neu = (await antwort.json()) as VorgangsStand;
|
||||||
|
if (abbrechen) return;
|
||||||
|
setGemerkt({ id: vorgangId, stand: neu });
|
||||||
|
if (!LAUFEND.has(neu.status)) return;
|
||||||
|
await new Promise((weiter) => setTimeout(weiter, 800));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void abfragen();
|
||||||
|
return () => {
|
||||||
|
abbrechen = true;
|
||||||
|
};
|
||||||
|
}, [vorgangId]);
|
||||||
|
|
||||||
|
const aktuell = gemerkt && gemerkt.id === vorgangId ? gemerkt : undefined;
|
||||||
|
return { stand: aktuell?.stand, abfrageFehler: aktuell?.fehler };
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
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;
|
||||||
|
/** 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 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}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { akteurVon, fehlerZustand, textfeld, type FormularZustand } from "@/server/actions/formular";
|
||||||
|
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import {
|
||||||
|
erzeugenAnfordern,
|
||||||
|
paarImportAnfordern,
|
||||||
|
publicKeyImportieren,
|
||||||
|
schluesselBearbeiten,
|
||||||
|
} from "@/server/services/schluessel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktionen fuer Schluessel (#012). Duenn: Recht pruefen, Felder lesen, Service rufen.
|
||||||
|
*
|
||||||
|
* Passphrase und Private Key verlassen diese Funktionen nur verschluesselt im Job (der
|
||||||
|
* Service verschluesselt sie) – nie in einer Antwort, einem Log oder dem Protokoll.
|
||||||
|
*/
|
||||||
|
export type SchluesselAntwort = FormularZustand & {
|
||||||
|
/** Vorgang im Worker (Erzeugen, Paar-Import) – die Oberflaeche verfolgt ihn. */
|
||||||
|
readonly vorgangId?: string;
|
||||||
|
/** Sofort angelegt (Public-Key-Import). */
|
||||||
|
readonly schluesselId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function metadaten(formular: FormData) {
|
||||||
|
return {
|
||||||
|
name: textfeld(formular, "name") ?? "",
|
||||||
|
slug: textfeld(formular, "slug"),
|
||||||
|
kommentar: textfeld(formular, "kommentar"),
|
||||||
|
tags: textfeld(formular, "tags") ?? "",
|
||||||
|
ablaufAm: textfeld(formular, "ablaufAm"),
|
||||||
|
personId: textfeld(formular, "personId"),
|
||||||
|
vorlageId: textfeld(formular, "vorlageId"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Passphrase mit Wiederholung – die Wiederholung prueft der Server, nicht nur das Formular. */
|
||||||
|
function passphraseLesen(formular: FormData): { passphrase?: string; fehler?: SchluesselAntwort } {
|
||||||
|
const passphrase = textfeld(formular, "passphrase");
|
||||||
|
const wiederholung = textfeld(formular, "passphraseWiederholung");
|
||||||
|
if (passphrase && passphrase !== wiederholung) {
|
||||||
|
return {
|
||||||
|
fehler: {
|
||||||
|
fehler: "Die Passphrasen stimmen nicht überein.",
|
||||||
|
feldfehler: { passphraseWiederholung: "Stimmt nicht mit der Passphrase überein." },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return passphrase ? { passphrase } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function schluesselErzeugenAktion(
|
||||||
|
_vorher: SchluesselAntwort,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<SchluesselAntwort> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("schluesselVerwalten");
|
||||||
|
const { passphrase, fehler } = passphraseLesen(formular);
|
||||||
|
if (fehler) return fehler;
|
||||||
|
const vorgangId = await erzeugenAnfordern(
|
||||||
|
{ ...metadaten(formular), typ: textfeld(formular, "typ") as "ED25519" | "RSA4096" | undefined },
|
||||||
|
passphrase,
|
||||||
|
akteurVon(benutzer),
|
||||||
|
);
|
||||||
|
return { vorgangId };
|
||||||
|
} catch (fehler) {
|
||||||
|
return fehlerZustand(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function paarImportierenAktion(
|
||||||
|
_vorher: SchluesselAntwort,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<SchluesselAntwort> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("schluesselVerwalten");
|
||||||
|
const vorgangId = await paarImportAnfordern(
|
||||||
|
metadaten(formular),
|
||||||
|
textfeld(formular, "privateKey"),
|
||||||
|
// Beim Import nur zur Pruefung – keine Wiederholung noetig, falsche merkt der Worker.
|
||||||
|
textfeld(formular, "passphrase"),
|
||||||
|
akteurVon(benutzer),
|
||||||
|
);
|
||||||
|
return { vorgangId };
|
||||||
|
} catch (fehler) {
|
||||||
|
return fehlerZustand(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publicKeyImportierenAktion(
|
||||||
|
_vorher: SchluesselAntwort,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<SchluesselAntwort> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("schluesselVerwalten");
|
||||||
|
const schluessel = await publicKeyImportieren(
|
||||||
|
{
|
||||||
|
...metadaten(formular),
|
||||||
|
kategorie: textfeld(formular, "kategorie") as "PERSON" | "DIENST" | undefined,
|
||||||
|
publicKey: textfeld(formular, "publicKey") ?? "",
|
||||||
|
},
|
||||||
|
akteurVon(benutzer),
|
||||||
|
);
|
||||||
|
revalidatePath("/schluessel");
|
||||||
|
return { schluesselId: schluessel.id };
|
||||||
|
} catch (fehler) {
|
||||||
|
return fehlerZustand(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function schluesselBearbeitenAktion(
|
||||||
|
_vorher: FormularZustand,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<FormularZustand> {
|
||||||
|
const id = textfeld(formular, "id") ?? "";
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("schluesselVerwalten");
|
||||||
|
await schluesselBearbeiten(
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
name: textfeld(formular, "name") ?? "",
|
||||||
|
tags: textfeld(formular, "tags") ?? "",
|
||||||
|
ablaufAm: textfeld(formular, "ablaufAm"),
|
||||||
|
personId: textfeld(formular, "personId"),
|
||||||
|
vorlageId: textfeld(formular, "vorlageId"),
|
||||||
|
status: textfeld(formular, "status") as "AKTIV" | "ABGELAUFEN" | undefined,
|
||||||
|
},
|
||||||
|
akteurVon(benutzer),
|
||||||
|
);
|
||||||
|
} catch (fehler) {
|
||||||
|
return fehlerZustand(fehler);
|
||||||
|
}
|
||||||
|
revalidatePath("/schluessel", "layout");
|
||||||
|
redirect(`/schluessel/${id}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
|
import type { SchluesselKategorie, SchluesselStatus } from "@/generated/prisma/enums";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lesemodelle fuer Schluessel (#012). Alles hier darf jede angemeldete Rolle sehen.
|
||||||
|
*
|
||||||
|
* Feste `select`-Listen ohne Chiffrat-Felder (CLAUDE.md, „Feste Regeln“). Ob ein Private Key
|
||||||
|
* gespeichert ist, verraet `masterKeyVersion`: der DB-CHECK `schluessel_umschlag_vollstaendig`
|
||||||
|
* setzt sie genau dann, wenn auch ein Chiffrat da ist – so muss das Chiffrat nie gelesen
|
||||||
|
* werden, nicht einmal fuer ein „ist null?“.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SCHLUESSEL_LISTE_SELECT = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
typ: true,
|
||||||
|
kategorie: true,
|
||||||
|
status: true,
|
||||||
|
publicKey: true,
|
||||||
|
fingerprint: true,
|
||||||
|
ablaufAm: true,
|
||||||
|
masterKeyVersion: true,
|
||||||
|
passphraseGeschuetzt: true,
|
||||||
|
person: { select: { id: true, name: true } },
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
publicVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
|
||||||
|
privateVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Prisma.SchluesselSelect;
|
||||||
|
|
||||||
|
export const SCHLUESSEL_DETAIL_SELECT = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
typ: true,
|
||||||
|
kategorie: true,
|
||||||
|
status: true,
|
||||||
|
publicKey: true,
|
||||||
|
fingerprint: true,
|
||||||
|
kommentar: true,
|
||||||
|
masterKeyVersion: true,
|
||||||
|
privatGeloeschtAm: true,
|
||||||
|
passphraseGeschuetzt: true,
|
||||||
|
instanzStandard: true,
|
||||||
|
selbstHinterlegt: true,
|
||||||
|
tags: true,
|
||||||
|
ablaufAm: true,
|
||||||
|
zuletztVerwendetAm: true,
|
||||||
|
angelegtAm: true,
|
||||||
|
personId: true,
|
||||||
|
vorlageId: true,
|
||||||
|
person: { select: { id: true, name: true } },
|
||||||
|
vorlage: { select: { id: true, name: true } },
|
||||||
|
erstelltVon: { select: { name: true } },
|
||||||
|
publicVerwendungen: {
|
||||||
|
where: { zustand: { not: "ENTFERNT" } },
|
||||||
|
orderBy: { angelegtAm: "asc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
zielbenutzer: true,
|
||||||
|
optionen: true,
|
||||||
|
zustand: true,
|
||||||
|
zuletztAngewendetAm: true,
|
||||||
|
host: { select: { id: true, name: true } },
|
||||||
|
hostgruppe: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
privateVerwendungen: {
|
||||||
|
where: { zustand: { not: "ENTFERNT" } },
|
||||||
|
orderBy: { angelegtAm: "asc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
zielbenutzer: true,
|
||||||
|
pfad: true,
|
||||||
|
sshConfig: true,
|
||||||
|
zustand: true,
|
||||||
|
zuletztAngewendetAm: true,
|
||||||
|
host: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Prisma.SchluesselSelect;
|
||||||
|
|
||||||
|
export type SchluesselFilter = {
|
||||||
|
readonly q?: string;
|
||||||
|
readonly kategorie?: string;
|
||||||
|
readonly status?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const KATEGORIEN: readonly SchluesselKategorie[] = ["PERSON", "DIENST", "DEPLOYMENT"];
|
||||||
|
const STATI: readonly SchluesselStatus[] = ["AKTIV", "ROTIERT", "WIDERRUFEN", "ABGELAUFEN"];
|
||||||
|
|
||||||
|
function alsKategorie(wert: string | undefined): SchluesselKategorie | undefined {
|
||||||
|
return KATEGORIEN.find((k) => k === wert);
|
||||||
|
}
|
||||||
|
|
||||||
|
function alsStatus(wert: string | undefined): SchluesselStatus | undefined {
|
||||||
|
return STATI.find((s) => s === wert);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function schluesselListe(filter: SchluesselFilter = {}) {
|
||||||
|
const q = filter.q?.trim().slice(0, 100);
|
||||||
|
const kategorie = alsKategorie(filter.kategorie);
|
||||||
|
const status = alsStatus(filter.status);
|
||||||
|
const where: Prisma.SchluesselWhereInput = {
|
||||||
|
...(kategorie ? { kategorie } : {}),
|
||||||
|
...(status ? { status } : {}),
|
||||||
|
...(q
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: q, mode: "insensitive" } },
|
||||||
|
{ slug: { contains: q.toLowerCase() } },
|
||||||
|
{ fingerprint: { contains: q } },
|
||||||
|
{ kommentar: { contains: q, mode: "insensitive" } },
|
||||||
|
{ tags: { has: q.toLowerCase() } },
|
||||||
|
{ person: { name: { contains: q, mode: "insensitive" } } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
const zeilen = await prisma.schluessel.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ name: "asc" }],
|
||||||
|
select: SCHLUESSEL_LISTE_SELECT,
|
||||||
|
});
|
||||||
|
return zeilen.map(({ _count, masterKeyVersion, ...rest }) => ({
|
||||||
|
...rest,
|
||||||
|
privatGespeichert: masterKeyVersion !== null,
|
||||||
|
verwendungen: _count.publicVerwendungen + _count.privateVerwendungen,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SchluesselListenEintrag = Awaited<ReturnType<typeof schluesselListe>>[number];
|
||||||
|
|
||||||
|
export async function schluesselDetail(id: string) {
|
||||||
|
const schluessel = await prisma.schluessel.findUnique({ where: { id }, select: SCHLUESSEL_DETAIL_SELECT });
|
||||||
|
if (!schluessel) return null;
|
||||||
|
const { masterKeyVersion, ...rest } = schluessel;
|
||||||
|
return { ...rest, privatGespeichert: masterKeyVersion !== null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SchluesselDetail = NonNullable<Awaited<ReturnType<typeof schluesselDetail>>>;
|
||||||
|
|
||||||
|
/** Nur Slug und Public Key – fuer den Download `<slug>.pub`. */
|
||||||
|
export async function publicKeyExport(id: string) {
|
||||||
|
return prisma.schluessel.findUnique({ where: { id }, select: { slug: true, publicKey: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Auswahllisten
|
||||||
|
|
||||||
|
export async function personenAuswahl() {
|
||||||
|
return prisma.person.findMany({
|
||||||
|
where: { archiviertAm: null },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: { id: true, name: true, organisation: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function vorlagenAuswahl() {
|
||||||
|
return prisma.vorlage.findMany({
|
||||||
|
where: { archiviertAm: null },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: { id: true, name: true, standardLaufzeitTage: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { HostFehler } from "@/server/services/hosts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fachlicher Fehler mit Meldung fuer die Oberflaeche und optional je Feld (#012, #013).
|
||||||
|
*
|
||||||
|
* Erbt von `HostFehler`, weil `fehlerZustand()` in src/server/actions/formular.ts genau den
|
||||||
|
* als „Meldung fuers Formular“ erkennt. So bleibt die eine Stelle, die Fehler in
|
||||||
|
* Formularzustaende uebersetzt, unveraendert.
|
||||||
|
*/
|
||||||
|
export class FachFehler extends HostFehler {
|
||||||
|
constructor(nachricht: string, feldfehler: Record<string, string> = {}) {
|
||||||
|
super(nachricht, feldfehler);
|
||||||
|
this.name = "FachFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { verschluesseln } from "@/lib/geheimnisse";
|
||||||
|
import { feldfehlerAus } from "@/lib/hosts";
|
||||||
|
import {
|
||||||
|
bearbeitenSchema,
|
||||||
|
erzeugenSchema,
|
||||||
|
freierSlug,
|
||||||
|
PASSPHRASE_MAX,
|
||||||
|
paarImportSchema,
|
||||||
|
PRIVATE_KEY_MAX,
|
||||||
|
publicImportSchema,
|
||||||
|
publicKeyZeileMitKommentar,
|
||||||
|
schluesselTypVon,
|
||||||
|
slugAus,
|
||||||
|
type BearbeitenEingabe,
|
||||||
|
type ErzeugenEingabe,
|
||||||
|
type PaarImportEingabe,
|
||||||
|
type PublicImportEingabe,
|
||||||
|
type SchluesselGeheimnis,
|
||||||
|
type SchluesselVorgangEingabe,
|
||||||
|
} from "@/lib/schluessel";
|
||||||
|
import { publicKeyZeileLesen, SchluesselFormatFehler } from "@/lib/ssh/openssh";
|
||||||
|
import type { z } from "@/lib/validierung";
|
||||||
|
import { FachFehler } from "@/server/services/fachfehler";
|
||||||
|
import { istEindeutigkeitsFehler, type Akteur } from "@/server/services/hosts";
|
||||||
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
|
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schluessel anlegen, importieren, bearbeiten (#012).
|
||||||
|
*
|
||||||
|
* Alles, was einen Private Key beruehrt (Erzeugen, Import eines Paars), wird hier nur
|
||||||
|
* geprueft und als Vorgang an den Worker gegeben – der Private Key reist mit
|
||||||
|
* SSHKV_APP_SECRET verschluesselt im Job, nie in `Vorgang.eingabe`. Ein reiner Public Key
|
||||||
|
* ist kein Geheimnis und wird direkt hier angelegt.
|
||||||
|
*
|
||||||
|
* Jede Prisma-Abfrage auf `Schluessel` hat ein festes `select` ohne Chiffrat-Felder
|
||||||
|
* (tests/schluessel-grenze.test.ts).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Was die Oberflaeche nach dem Anlegen braucht – und nicht mehr. */
|
||||||
|
const ANGELEGT = { id: true, slug: true, fingerprint: true } satisfies Prisma.SchluesselSelect;
|
||||||
|
|
||||||
|
function pruefen<S extends z.ZodType>(schema: S, eingabe: unknown): z.output<S> {
|
||||||
|
const ergebnis = schema.safeParse(eingabe);
|
||||||
|
if (!ergebnis.success) {
|
||||||
|
throw new FachFehler("Bitte die markierten Felder prüfen.", feldfehlerAus(ergebnis.error));
|
||||||
|
}
|
||||||
|
return ergebnis.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function slugFrei(slug: string, client: Prisma.TransactionClient = prisma): Promise<boolean> {
|
||||||
|
return (await client.schluessel.findUnique({ where: { slug }, select: { id: true } })) === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein ausdruecklich gewuenschter Slug muss frei sein; ein aus dem Namen abgeleiteter bekommt
|
||||||
|
* bei Bedarf eine Nummer angehaengt.
|
||||||
|
*/
|
||||||
|
export async function eindeutigerSlug(wunsch: string | null, name: string): Promise<string> {
|
||||||
|
if (wunsch) {
|
||||||
|
if (!(await slugFrei(wunsch))) {
|
||||||
|
throw new FachFehler(`Der Kurzname „${wunsch}“ ist schon vergeben.`, {
|
||||||
|
slug: "Dieser Kurzname ist schon vergeben.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return wunsch;
|
||||||
|
}
|
||||||
|
return freierSlug(slugAus(name), (kandidat) => slugFrei(kandidat));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gibt es diesen Fingerprint schon? Fuer eine Meldung mit Verweis auf den vorhandenen Key. */
|
||||||
|
export async function schluesselMitFingerprint(fingerprint: string) {
|
||||||
|
return prisma.schluessel.findUnique({
|
||||||
|
where: { fingerprint },
|
||||||
|
select: { id: true, name: true, slug: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bereitsVerwaltet(vorhanden: { id: string; name: string }): FachFehler {
|
||||||
|
return new FachFehler(
|
||||||
|
`Dieser Schlüssel wird bereits verwaltet: „${vorhanden.name}“ (/schluessel/${vorhanden.id}).`,
|
||||||
|
{ publicKey: "Dieser Schlüssel ist schon in sshkv." },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Heute 00:00 UTC – Vergleichsbasis fuer „Ablaufdatum liegt nicht in der Vergangenheit“. */
|
||||||
|
function heute(jetzt = new Date()): Date {
|
||||||
|
return new Date(Date.UTC(jetzt.getUTCFullYear(), jetzt.getUTCMonth(), jetzt.getUTCDate()));
|
||||||
|
}
|
||||||
|
|
||||||
|
type Bezuege = { readonly personId: string | null; readonly vorlageId: string | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft Person und Vorlage und setzt ein fehlendes Ablaufdatum aus der Standardlaufzeit der
|
||||||
|
* Vorlage. Exportiert, weil der Worker dieselbe Pruefung beim Ausfuehren wiederholt.
|
||||||
|
*/
|
||||||
|
export async function bezuegePruefen(
|
||||||
|
daten: Bezuege & { readonly ablaufAm: Date | null },
|
||||||
|
client: Prisma.TransactionClient = prisma,
|
||||||
|
): Promise<{ ablaufAm: Date | null }> {
|
||||||
|
if (daten.personId) {
|
||||||
|
const person = await client.person.findUnique({
|
||||||
|
where: { id: daten.personId },
|
||||||
|
select: { archiviertAm: true },
|
||||||
|
});
|
||||||
|
if (!person || person.archiviertAm) {
|
||||||
|
throw new FachFehler("Die gewählte Person gibt es nicht (mehr).", { personId: "Bitte neu wählen." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let ablaufAm = daten.ablaufAm;
|
||||||
|
if (daten.vorlageId) {
|
||||||
|
const vorlage = await client.vorlage.findUnique({
|
||||||
|
where: { id: daten.vorlageId },
|
||||||
|
select: { archiviertAm: true, standardLaufzeitTage: true },
|
||||||
|
});
|
||||||
|
if (!vorlage || vorlage.archiviertAm) {
|
||||||
|
throw new FachFehler("Die gewählte Vorlage gibt es nicht (mehr).", { vorlageId: "Bitte neu wählen." });
|
||||||
|
}
|
||||||
|
if (!ablaufAm && vorlage.standardLaufzeitTage) {
|
||||||
|
ablaufAm = new Date(heute().getTime() + vorlage.standardLaufzeitTage * 24 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ablaufAm };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ablaufNichtVergangen(ablaufAm: Date | null): void {
|
||||||
|
if (ablaufAm && ablaufAm < heute()) {
|
||||||
|
throw new FachFehler("Das Ablaufdatum liegt in der Vergangenheit.", {
|
||||||
|
ablaufAm: "Bitte ein Datum ab heute wählen.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function passphrasePruefen(passphrase: string | undefined): string | undefined {
|
||||||
|
if (!passphrase) return undefined;
|
||||||
|
if (passphrase.length > PASSPHRASE_MAX) {
|
||||||
|
throw new FachFehler("Die Passphrase ist zu lang.", {
|
||||||
|
passphrase: `Höchstens ${PASSPHRASE_MAX} Zeichen.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return passphrase;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Erzeugen und Paar-Import
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fordert ein neues Schluesselpaar an (#012). Liefert die Vorgangs-ID; die Oberflaeche
|
||||||
|
* verfolgt den Vorgang und springt nach Erfolg zum Schluessel.
|
||||||
|
*/
|
||||||
|
export async function erzeugenAnfordern(
|
||||||
|
eingabe: ErzeugenEingabe,
|
||||||
|
passphraseRoh: string | undefined,
|
||||||
|
akteur: Akteur,
|
||||||
|
): Promise<string> {
|
||||||
|
const daten = pruefen(erzeugenSchema, eingabe);
|
||||||
|
const passphrase = passphrasePruefen(passphraseRoh);
|
||||||
|
ablaufNichtVergangen(daten.ablaufAm);
|
||||||
|
const { ablaufAm } = await bezuegePruefen(daten);
|
||||||
|
const slug = await eindeutigerSlug(daten.slug, daten.name);
|
||||||
|
|
||||||
|
const vorgangsEingabe: SchluesselVorgangEingabe = {
|
||||||
|
name: daten.name,
|
||||||
|
slug,
|
||||||
|
tags: daten.tags,
|
||||||
|
ablaufAm: ablaufAm?.toISOString() ?? null,
|
||||||
|
personId: daten.personId,
|
||||||
|
vorlageId: daten.vorlageId,
|
||||||
|
kommentar: daten.kommentar,
|
||||||
|
typ: daten.typ,
|
||||||
|
mitPassphrase: Boolean(passphrase),
|
||||||
|
};
|
||||||
|
const geheimnis: SchluesselGeheimnis = passphrase ? { passphrase } : {};
|
||||||
|
return vorgangEinreihen({
|
||||||
|
art: "SCHLUESSEL_ERZEUGEN",
|
||||||
|
angefordertVonId: akteur.id,
|
||||||
|
eingabe: vorgangsEingabe,
|
||||||
|
...(passphrase ? { geheim: verschluesseln(JSON.stringify(geheimnis)) } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fordert den Import eines Paars an (#012). Der Private Key wird hier nur grob angesehen
|
||||||
|
* (Groesse, Kopfzeile) – lesen, pruefen und versiegeln tut der Worker. Eine Passphrase dient
|
||||||
|
* dort nur der Pruefung und wird verworfen; gespeichert wird der Key, wie er kam.
|
||||||
|
*/
|
||||||
|
export async function paarImportAnfordern(
|
||||||
|
eingabe: PaarImportEingabe,
|
||||||
|
privateKey: string | undefined,
|
||||||
|
passphraseRoh: string | undefined,
|
||||||
|
akteur: Akteur,
|
||||||
|
): Promise<string> {
|
||||||
|
const daten = pruefen(paarImportSchema, eingabe);
|
||||||
|
// Wie ssh-keygen ihn schreibt: LF-Zeilenenden und genau ein Zeilenumbruch am Ende.
|
||||||
|
const text = (privateKey ?? "").replace(/\r\n?/g, "\n").trim();
|
||||||
|
if (!text) {
|
||||||
|
throw new FachFehler("Bitte den Private Key einfügen.", {
|
||||||
|
privateKey: "Bitte den Private Key einfügen.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (text.length > PRIVATE_KEY_MAX || !text.startsWith("-----BEGIN OPENSSH PRIVATE KEY-----")) {
|
||||||
|
// Feste Meldung – nie etwas aus dem eingefuegten Text zurueckgeben.
|
||||||
|
throw new FachFehler("Das ist kein Private Key im OpenSSH-Format.", {
|
||||||
|
privateKey: "Erwartet wird „-----BEGIN OPENSSH PRIVATE KEY-----“ …",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const passphrase = passphrasePruefen(passphraseRoh);
|
||||||
|
ablaufNichtVergangen(daten.ablaufAm);
|
||||||
|
const { ablaufAm } = await bezuegePruefen(daten);
|
||||||
|
const slug = await eindeutigerSlug(daten.slug, daten.name);
|
||||||
|
|
||||||
|
const vorgangsEingabe: SchluesselVorgangEingabe = {
|
||||||
|
name: daten.name,
|
||||||
|
slug,
|
||||||
|
tags: daten.tags,
|
||||||
|
ablaufAm: ablaufAm?.toISOString() ?? null,
|
||||||
|
personId: daten.personId,
|
||||||
|
vorlageId: daten.vorlageId,
|
||||||
|
kommentar: daten.kommentar,
|
||||||
|
mitPassphrase: Boolean(passphrase),
|
||||||
|
};
|
||||||
|
const geheimnis: SchluesselGeheimnis = { privateKey: `${text}\n`, ...(passphrase ? { passphrase } : {}) };
|
||||||
|
return vorgangEinreihen({
|
||||||
|
art: "SCHLUESSEL_IMPORTIEREN",
|
||||||
|
angefordertVonId: akteur.id,
|
||||||
|
eingabe: vorgangsEingabe,
|
||||||
|
geheim: verschluesseln(JSON.stringify(geheimnis)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Public-Key-Import
|
||||||
|
|
||||||
|
/** Liest eine Public-Key-Zeile und uebersetzt Formatfehler in eine Feldmeldung. */
|
||||||
|
export function publicKeyPruefen(zeile: string) {
|
||||||
|
try {
|
||||||
|
// Die RSA-Mindestlaenge gilt beim Import (Standard von publicKeyZeileLesen).
|
||||||
|
return publicKeyZeileLesen(zeile);
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof SchluesselFormatFehler) {
|
||||||
|
throw new FachFehler(fehler.message, { publicKey: fehler.message });
|
||||||
|
}
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt einen Schluessel nur mit Public Key an (#012). Auch Grundlage des Self-Service
|
||||||
|
* (#013), der Kategorie, Person und `selbstHinterlegt` selbst setzt.
|
||||||
|
*/
|
||||||
|
export async function publicKeyAnlegen(
|
||||||
|
daten: {
|
||||||
|
readonly name: string;
|
||||||
|
readonly slug: string | null;
|
||||||
|
readonly kategorie: "PERSON" | "DIENST";
|
||||||
|
readonly publicKey: string;
|
||||||
|
readonly kommentar: string | null;
|
||||||
|
readonly tags: string[];
|
||||||
|
readonly ablaufAm: Date | null;
|
||||||
|
readonly personId: string | null;
|
||||||
|
readonly vorlageId: string | null;
|
||||||
|
readonly selbstHinterlegt?: boolean;
|
||||||
|
},
|
||||||
|
akteur: Akteur,
|
||||||
|
aktion = "schluessel.importiert",
|
||||||
|
) {
|
||||||
|
const info = publicKeyPruefen(daten.publicKey);
|
||||||
|
const vorhanden = await schluesselMitFingerprint(info.fingerprint);
|
||||||
|
if (vorhanden) throw bereitsVerwaltet(vorhanden);
|
||||||
|
ablaufNichtVergangen(daten.ablaufAm);
|
||||||
|
const { ablaufAm } = await bezuegePruefen(daten);
|
||||||
|
const slug = await eindeutigerSlug(daten.slug, daten.name);
|
||||||
|
const kommentar = daten.kommentar ?? (info.kommentar || `sshkv:${slug}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await prisma.$transaction(async (tx) => {
|
||||||
|
const schluessel = await tx.schluessel.create({
|
||||||
|
data: {
|
||||||
|
name: daten.name,
|
||||||
|
slug,
|
||||||
|
typ: schluesselTypVon(info.typ, info.bits),
|
||||||
|
kategorie: daten.kategorie,
|
||||||
|
publicKey: publicKeyZeileMitKommentar(daten.publicKey, kommentar),
|
||||||
|
fingerprint: info.fingerprint,
|
||||||
|
kommentar,
|
||||||
|
tags: daten.tags,
|
||||||
|
ablaufAm,
|
||||||
|
personId: daten.personId,
|
||||||
|
vorlageId: daten.vorlageId,
|
||||||
|
erstelltVonId: akteur.id,
|
||||||
|
selbstHinterlegt: daten.selbstHinterlegt ?? false,
|
||||||
|
},
|
||||||
|
select: ANGELEGT,
|
||||||
|
});
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
akteur,
|
||||||
|
aktion,
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
fingerprint: schluessel.fingerprint,
|
||||||
|
details: {
|
||||||
|
name: daten.name,
|
||||||
|
slug,
|
||||||
|
kategorie: daten.kategorie,
|
||||||
|
art: "public",
|
||||||
|
algorithmus: info.typ,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
return schluessel;
|
||||||
|
});
|
||||||
|
} catch (fehler) {
|
||||||
|
// Zwischen Pruefung und Insert kann derselbe Key oder Slug angelegt worden sein.
|
||||||
|
if (istEindeutigkeitsFehler(fehler)) {
|
||||||
|
const jetzt = await schluesselMitFingerprint(info.fingerprint);
|
||||||
|
if (jetzt) throw bereitsVerwaltet(jetzt);
|
||||||
|
throw new FachFehler("Der Kurzname wurde gerade vergeben. Bitte erneut speichern.");
|
||||||
|
}
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publicKeyImportieren(eingabe: PublicImportEingabe, akteur: Akteur) {
|
||||||
|
const daten = pruefen(publicImportSchema, eingabe);
|
||||||
|
return publicKeyAnlegen(daten, akteur);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Bearbeiten
|
||||||
|
|
||||||
|
export async function schluesselBearbeiten(id: string, eingabe: BearbeitenEingabe, akteur: Akteur) {
|
||||||
|
const daten = pruefen(bearbeitenSchema, eingabe);
|
||||||
|
const alt = await prisma.schluessel.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
kategorie: true,
|
||||||
|
status: true,
|
||||||
|
tags: true,
|
||||||
|
ablaufAm: true,
|
||||||
|
personId: true,
|
||||||
|
vorlageId: true,
|
||||||
|
fingerprint: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!alt) throw new FachFehler("Diesen Schlüssel gibt es nicht.");
|
||||||
|
if (daten.status && daten.status !== alt.status) {
|
||||||
|
if (alt.status !== "AKTIV" && alt.status !== "ABGELAUFEN") {
|
||||||
|
throw new FachFehler("Rotierte oder widerrufene Schlüssel lassen sich nicht wieder aktivieren.", {
|
||||||
|
status: "Status nicht änderbar.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Der Deployment-Key der Instanz wird nur ueber seine eigene Rotation ersetzt; ein
|
||||||
|
// „abgelaufener“ Deployment-Key liesse den Worker beim naechsten Start einen neuen erzeugen.
|
||||||
|
if (alt.kategorie === "DEPLOYMENT") {
|
||||||
|
throw new FachFehler("Der Status eines Deployment-Keys lässt sich hier nicht ändern.", {
|
||||||
|
status: "Status nicht änderbar.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (daten.ablaufAm?.getTime() !== alt.ablaufAm?.getTime()) ablaufNichtVergangen(daten.ablaufAm);
|
||||||
|
if (daten.personId !== alt.personId || daten.vorlageId !== alt.vorlageId) {
|
||||||
|
await bezuegePruefen({ personId: daten.personId, vorlageId: daten.vorlageId, ablaufAm: daten.ablaufAm });
|
||||||
|
}
|
||||||
|
|
||||||
|
const nachher = {
|
||||||
|
name: daten.name,
|
||||||
|
tags: daten.tags,
|
||||||
|
ablaufAm: daten.ablaufAm,
|
||||||
|
personId: daten.personId,
|
||||||
|
vorlageId: daten.vorlageId,
|
||||||
|
status: daten.status ?? alt.status,
|
||||||
|
};
|
||||||
|
const geaendert = Object.fromEntries(
|
||||||
|
Object.entries(nachher)
|
||||||
|
.map(([feld, wert]) => [feld, { vorher: alt[feld as keyof typeof nachher], nachher: wert }] as const)
|
||||||
|
.filter(([, a]) => JSON.stringify(a.vorher) !== JSON.stringify(a.nachher)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const schluessel = await tx.schluessel.update({ where: { id }, data: nachher, select: ANGELEGT });
|
||||||
|
if (Object.keys(geaendert).length > 0) {
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
akteur,
|
||||||
|
aktion: "schluessel.geaendert",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
fingerprint: alt.fingerprint,
|
||||||
|
details: geaendert,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return schluessel;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import type { Vorgang } from "@/generated/prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { entschluesseln } from "@/lib/geheimnisse";
|
||||||
|
import {
|
||||||
|
publicKeyZeileMitKommentar,
|
||||||
|
schluesselTypVon,
|
||||||
|
vorgangEingabeSchema,
|
||||||
|
type SchluesselGeheimnis,
|
||||||
|
} from "@/lib/schluessel";
|
||||||
|
import { privateKeyLesen, SchluesselFormatFehler } from "@/lib/ssh/openssh";
|
||||||
|
import { HostFehler, istEindeutigkeitsFehler } from "@/server/services/hosts";
|
||||||
|
import { protokollieren, type ProtokollEintrag } from "@/server/services/protokoll";
|
||||||
|
import { bezuegePruefen, schluesselMitFingerprint } from "@/server/services/schluessel";
|
||||||
|
import { VorgangsFehler, type Handler } from "@/worker/ausfuehren";
|
||||||
|
import { importiertenKeyVersiegeln, schluesselpaarVersiegeltErzeugen } from "@/worker/geheimnisse";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schluessel erzeugen und ein Paar importieren (#012) – im Worker, weil nur er den Master-Key
|
||||||
|
* hat. Passphrase und importierter Private Key kommen mit SSHKV_APP_SECRET verschluesselt im
|
||||||
|
* Job (`geheim`); der Worker loescht den Job nach dem Lauf (src/worker/index.ts).
|
||||||
|
*
|
||||||
|
* Fehlermeldungen sind feste Saetze: aus `SchluesselFormatFehler` (die nie Eingaben
|
||||||
|
* zitieren) oder von hier. Nichts aus dem Private Key landet in `Vorgang.fehler`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Wer den Vorgang angefordert hat – fuer Protokoll und `erstelltVonId`. */
|
||||||
|
async function anforderer(vorgang: Vorgang): Promise<ProtokollEintrag["akteur"]> {
|
||||||
|
if (!vorgang.angefordertVonId) return "System";
|
||||||
|
const benutzer = await prisma.benutzer.findUnique({
|
||||||
|
where: { id: vorgang.angefordertVonId },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
return benutzer ?? "System";
|
||||||
|
}
|
||||||
|
|
||||||
|
function geheimnisLesen(geheim: string | undefined): SchluesselGeheimnis {
|
||||||
|
if (!geheim) return {};
|
||||||
|
try {
|
||||||
|
const wert = JSON.parse(entschluesseln(geheim)) as unknown;
|
||||||
|
if (!wert || typeof wert !== "object") throw new Error("kein Objekt");
|
||||||
|
const { privateKey, passphrase } = wert as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
...(typeof privateKey === "string" ? { privateKey } : {}),
|
||||||
|
...(typeof passphrase === "string" && passphrase ? { passphrase } : {}),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
throw new VorgangsFehler("Die verschlüsselten Angaben des Vorgangs sind unlesbar. Bitte erneut starten.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eingabe pruefen, Bezuege nachsehen – Fachfehler werden zu Meldungen des Vorgangs. */
|
||||||
|
async function eingabeAufloesen(vorgang: Vorgang) {
|
||||||
|
const ergebnis = vorgangEingabeSchema.safeParse(vorgang.eingabe);
|
||||||
|
if (!ergebnis.success) throw new VorgangsFehler("Die Angaben zum Schlüssel sind unvollständig.");
|
||||||
|
const eingabe = ergebnis.data;
|
||||||
|
try {
|
||||||
|
const { ablaufAm } = await bezuegePruefen({
|
||||||
|
personId: eingabe.personId,
|
||||||
|
vorlageId: eingabe.vorlageId,
|
||||||
|
ablaufAm: eingabe.ablaufAm ? new Date(eingabe.ablaufAm) : null,
|
||||||
|
});
|
||||||
|
return { ...eingabe, ablaufAm };
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof HostFehler) throw new VorgangsFehler(fehler.message);
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function schonVerwaltet(vorhanden: { name: string }): VorgangsFehler {
|
||||||
|
return new VorgangsFehler(`Dieser Schlüssel wird bereits verwaltet: „${vorhanden.name}“.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Vorgang soll auf „seinen“ Schluessel zeigen – fuer Verlauf und Oberflaeche. */
|
||||||
|
async function vorgangVerknuepfen(vorgangId: string, schluesselId: string): Promise<void> {
|
||||||
|
await prisma.vorgang.update({ where: { id: vorgangId }, data: { schluesselId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const schluesselErzeugenHandler: Handler = async ({ vorgang, geheim }) => {
|
||||||
|
const eingabe = await eingabeAufloesen(vorgang);
|
||||||
|
const { passphrase } = geheimnisLesen(geheim);
|
||||||
|
if (eingabe.mitPassphrase && !passphrase) {
|
||||||
|
throw new VorgangsFehler("Die Passphrase fehlt – bitte erneut erzeugen.");
|
||||||
|
}
|
||||||
|
const akteur = await anforderer(vorgang);
|
||||||
|
const typ = eingabe.typ ?? "ED25519";
|
||||||
|
const kommentar = eingabe.kommentar ?? `sshkv:${eingabe.slug}`;
|
||||||
|
|
||||||
|
const neu = await schluesselpaarVersiegeltErzeugen(typ, kommentar, passphrase);
|
||||||
|
try {
|
||||||
|
await prisma.schluessel.create({
|
||||||
|
data: {
|
||||||
|
id: neu.id,
|
||||||
|
name: eingabe.name,
|
||||||
|
slug: eingabe.slug,
|
||||||
|
typ,
|
||||||
|
kategorie: "DIENST",
|
||||||
|
publicKey: neu.publicKeyZeile,
|
||||||
|
fingerprint: neu.fingerprint,
|
||||||
|
kommentar,
|
||||||
|
tags: eingabe.tags,
|
||||||
|
ablaufAm: eingabe.ablaufAm,
|
||||||
|
personId: eingabe.personId,
|
||||||
|
vorlageId: eingabe.vorlageId,
|
||||||
|
erstelltVonId: akteur === "System" ? null : akteur.id,
|
||||||
|
passphraseGeschuetzt: Boolean(passphrase),
|
||||||
|
...neu.versiegelt,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
} catch (fehler) {
|
||||||
|
if (istEindeutigkeitsFehler(fehler)) {
|
||||||
|
throw new VorgangsFehler(
|
||||||
|
`Der Kurzname „${eingabe.slug}“ wurde inzwischen vergeben. Bitte erneut erzeugen.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
await vorgangVerknuepfen(vorgang.id, neu.id);
|
||||||
|
await protokollieren({
|
||||||
|
akteur,
|
||||||
|
aktion: "schluessel.erzeugt",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
fingerprint: neu.fingerprint,
|
||||||
|
details: {
|
||||||
|
name: eingabe.name,
|
||||||
|
slug: eingabe.slug,
|
||||||
|
typ,
|
||||||
|
kategorie: "DIENST",
|
||||||
|
passphraseGeschuetzt: Boolean(passphrase),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { ergebnis: { schluesselId: neu.id, fingerprint: neu.fingerprint } };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const schluesselImportierenHandler: Handler = async ({ vorgang, geheim }) => {
|
||||||
|
const eingabe = await eingabeAufloesen(vorgang);
|
||||||
|
const { privateKey, passphrase } = geheimnisLesen(geheim);
|
||||||
|
if (!privateKey) throw new VorgangsFehler("Der Private Key fehlt – bitte den Import erneut starten.");
|
||||||
|
const akteur = await anforderer(vorgang);
|
||||||
|
|
||||||
|
let info;
|
||||||
|
try {
|
||||||
|
// Die Passphrase dient nur dem Lesen von Public Key und Fingerprint und wird danach
|
||||||
|
// verworfen; gespeichert wird der Key so, wie er kam (Freigabe Frage 6).
|
||||||
|
info = privateKeyLesen(privateKey, passphrase);
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof SchluesselFormatFehler) throw new VorgangsFehler(fehler.message);
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
const vorhanden = await schluesselMitFingerprint(info.fingerprint);
|
||||||
|
if (vorhanden) throw schonVerwaltet(vorhanden);
|
||||||
|
|
||||||
|
const algorithmus = info.typ === "ED25519" ? "ssh-ed25519" : "ssh-rsa";
|
||||||
|
const typ = schluesselTypVon(algorithmus, info.bits);
|
||||||
|
const kommentar = eingabe.kommentar ?? (info.kommentar || `sshkv:${eingabe.slug}`);
|
||||||
|
const { id, versiegelt } = await importiertenKeyVersiegeln(info.fingerprint, privateKey);
|
||||||
|
try {
|
||||||
|
await prisma.schluessel.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
name: eingabe.name,
|
||||||
|
slug: eingabe.slug,
|
||||||
|
typ,
|
||||||
|
kategorie: "DIENST",
|
||||||
|
publicKey: publicKeyZeileMitKommentar(info.publicKeyZeile, kommentar),
|
||||||
|
fingerprint: info.fingerprint,
|
||||||
|
kommentar,
|
||||||
|
tags: eingabe.tags,
|
||||||
|
ablaufAm: eingabe.ablaufAm,
|
||||||
|
personId: eingabe.personId,
|
||||||
|
vorlageId: eingabe.vorlageId,
|
||||||
|
erstelltVonId: akteur === "System" ? null : akteur.id,
|
||||||
|
passphraseGeschuetzt: info.verschluesselt,
|
||||||
|
...versiegelt,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
} catch (fehler) {
|
||||||
|
if (istEindeutigkeitsFehler(fehler)) {
|
||||||
|
const jetzt = await schluesselMitFingerprint(info.fingerprint);
|
||||||
|
if (jetzt) throw schonVerwaltet(jetzt);
|
||||||
|
throw new VorgangsFehler(
|
||||||
|
`Der Kurzname „${eingabe.slug}“ wurde inzwischen vergeben. Bitte erneut importieren.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
await vorgangVerknuepfen(vorgang.id, id);
|
||||||
|
await protokollieren({
|
||||||
|
akteur,
|
||||||
|
aktion: "schluessel.importiert",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
fingerprint: info.fingerprint,
|
||||||
|
details: {
|
||||||
|
name: eingabe.name,
|
||||||
|
slug: eingabe.slug,
|
||||||
|
typ,
|
||||||
|
kategorie: "DIENST",
|
||||||
|
art: "paar",
|
||||||
|
passphraseGeschuetzt: info.verschluesselt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { ergebnis: { schluesselId: id, fingerprint: info.fingerprint } };
|
||||||
|
};
|
||||||
@@ -126,13 +126,16 @@ export type NeuerSchluessel = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Erzeugt ein Schluesselpaar und versiegelt den Private Key sofort. Die ID wird hier
|
* Erzeugt ein Schluesselpaar und versiegelt den Private Key sofort. Die ID wird hier
|
||||||
* vergeben, weil sie in die Zusatzdaten des Umschlags eingeht.
|
* vergeben, weil sie in die Zusatzdaten des Umschlags eingeht. Mit `passphrase` liegt im
|
||||||
|
* Umschlag der bereits passphrasengeschuetzte OpenSSH-Key; die Passphrase selbst wird nie
|
||||||
|
* gespeichert (Freigabe Frage 6, #012).
|
||||||
*/
|
*/
|
||||||
export async function schluesselpaarVersiegeltErzeugen(
|
export async function schluesselpaarVersiegeltErzeugen(
|
||||||
typ: ErzeugbarerSchluesseltyp,
|
typ: ErzeugbarerSchluesseltyp,
|
||||||
kommentar: string,
|
kommentar: string,
|
||||||
|
passphrase?: string,
|
||||||
): Promise<NeuerSchluessel> {
|
): Promise<NeuerSchluessel> {
|
||||||
const paar = await schluesselpaarErzeugen(typ, { kommentar });
|
const paar = await schluesselpaarErzeugen(typ, { kommentar, ...(passphrase ? { passphrase } : {}) });
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -142,6 +145,35 @@ export async function schluesselpaarVersiegeltErzeugen(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versiegelt einen importierten Private Key (#012) so, wie er kam – ein
|
||||||
|
* passphrasengeschuetzter Key bleibt es. Die ID wird hier vergeben (Zusatzdaten).
|
||||||
|
*/
|
||||||
|
export async function importiertenKeyVersiegeln(
|
||||||
|
fingerprint: string,
|
||||||
|
privateKeyOpenssh: string,
|
||||||
|
): Promise<{ readonly id: string; readonly versiegelt: VersiegelterKey }> {
|
||||||
|
const id = randomUUID();
|
||||||
|
return { id, versiegelt: await privateKeyVersiegeln(id, fingerprint, privateKeyOpenssh) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verwirft den gespeicherten Private Key endgueltig (#014): Chiffrat, Data-Key und Version
|
||||||
|
* auf null, Zeitpunkt festhalten. Liefert false, wenn keiner (mehr) gespeichert war.
|
||||||
|
*/
|
||||||
|
export async function privateKeyVerwerfen(schluesselId: string): Promise<boolean> {
|
||||||
|
const ergebnis = await prisma.schluessel.updateMany({
|
||||||
|
where: { id: schluesselId, privatVerschluesselt: { not: null } },
|
||||||
|
data: {
|
||||||
|
privatVerschluesselt: null,
|
||||||
|
dekVerschluesselt: null,
|
||||||
|
masterKeyVersion: null,
|
||||||
|
privatGeloeschtAm: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return ergebnis.count === 1;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stellt beim Start des Workers sicher, dass es einen Deployment-Key der Instanz gibt
|
* Stellt beim Start des Workers sicher, dass es einen Deployment-Key der Instanz gibt
|
||||||
* (#006). Gibt die ID zurueck. Gleichzeitige Starts scheitern am partiellen Index
|
* (#006). Gibt die ID zurueck. Gleichzeitige Starts scheitern am partiellen Index
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { HandlerTabelle } from "@/worker/ausfuehren";
|
import type { HandlerTabelle } from "@/worker/ausfuehren";
|
||||||
import { bootstrapHandler } from "@/worker/ablaeufe/bootstrap";
|
import { bootstrapHandler } from "@/worker/ablaeufe/bootstrap";
|
||||||
import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey";
|
import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey";
|
||||||
|
import { schluesselErzeugenHandler, schluesselImportierenHandler } from "@/worker/ablaeufe/schluessel";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Ausrollen (#016), Abgleich (#021) …
|
* Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Ausrollen (#016), Abgleich (#021) …
|
||||||
@@ -9,4 +10,6 @@ export const HANDLER: HandlerTabelle = {
|
|||||||
HOSTKEY_ABRUFEN: hostKeyAbrufenHandler,
|
HOSTKEY_ABRUFEN: hostKeyAbrufenHandler,
|
||||||
ERREICHBARKEIT: erreichbarkeitHandler,
|
ERREICHBARKEIT: erreichbarkeitHandler,
|
||||||
BOOTSTRAP: bootstrapHandler,
|
BOOTSTRAP: bootstrapHandler,
|
||||||
|
SCHLUESSEL_ERZEUGEN: schluesselErzeugenHandler,
|
||||||
|
SCHLUESSEL_IMPORTIEREN: schluesselImportierenHandler,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ const REGELN = [
|
|||||||
"privateverwendung_pfad_gueltig",
|
"privateverwendung_pfad_gueltig",
|
||||||
"publicverwendung_host_oder_gruppe",
|
"publicverwendung_host_oder_gruppe",
|
||||||
"benutzer_notfall_vollstaendig",
|
"benutzer_notfall_vollstaendig",
|
||||||
|
"schluessel_slug_gueltig",
|
||||||
|
"schluessel_geloescht_ohne_private_key",
|
||||||
].sort();
|
].sort();
|
||||||
|
|
||||||
const INDIZES = [
|
const INDIZES = [
|
||||||
@@ -90,6 +92,25 @@ describe("Datenbankregeln", () => {
|
|||||||
).rejects.toThrow();
|
).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("laesst nur Slugs zu, die in Dateinamen passen (#012)", async () => {
|
||||||
|
for (const slug of ["Gross", "mit leer", "../x", "a_b", "", "x".repeat(65)]) {
|
||||||
|
await expect(schluesselAnlegen({ slug })).rejects.toThrow();
|
||||||
|
}
|
||||||
|
await expect(schluesselAnlegen({ slug: "backup-srv-pve01" })).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kennt „Private Key geloescht“ nur ohne Chiffrat (#014)", async () => {
|
||||||
|
await expect(
|
||||||
|
schluesselAnlegen({
|
||||||
|
privatGeloeschtAm: new Date(),
|
||||||
|
privatVerschluesselt: Buffer.from("x"),
|
||||||
|
dekVerschluesselt: Buffer.from("y"),
|
||||||
|
masterKeyVersion: 1,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
await expect(schluesselAnlegen({ privatGeloeschtAm: new Date() })).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("laesst nur einen aktiven Instanz-Deployment-Key zu", async () => {
|
it("laesst nur einen aktiven Instanz-Deployment-Key zu", async () => {
|
||||||
await expect(schluesselAnlegen({ kategorie: "DIENST", instanzStandard: true })).rejects.toThrow();
|
await expect(schluesselAnlegen({ kategorie: "DIENST", instanzStandard: true })).rejects.toThrow();
|
||||||
await schluesselAnlegen({ kategorie: "DEPLOYMENT", instanzStandard: true });
|
await schluesselAnlegen({ kategorie: "DEPLOYMENT", instanzStandard: true });
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
import "dotenv/config";
|
||||||
|
import { schluesselpaarErzeugen } from "../../src/lib/ssh/openssh";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schluessel im gebauten Stand mit laufendem Worker (#012–#014). Testdaten heissen zz-e2e-…
|
||||||
|
* und bleiben liegen (Schluessel werden nicht geloescht, nur spaeter widerrufen).
|
||||||
|
*/
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
|
||||||
|
async function oidcAnmelden(page: Page, rolle: "admin" | "operator" | "reader") {
|
||||||
|
await page.goto("/anmelden");
|
||||||
|
await page.getByRole("link", { name: /Mit Mock-OIDC anmelden/ }).click();
|
||||||
|
await page.locator(`button[data-rolle="${rolle}"]`).click();
|
||||||
|
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
function eindeutig(): string {
|
||||||
|
return `zz-e2e-${Date.now().toString(36)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Operator erzeugt einen Schlüssel und landet im Detail", async ({ page }) => {
|
||||||
|
const name = `${eindeutig()} Backup`;
|
||||||
|
await oidcAnmelden(page, "operator");
|
||||||
|
await page.goto("/schluessel/neu");
|
||||||
|
await page.getByLabel("Zweck").fill(name);
|
||||||
|
await page.getByLabel("Tags").fill("e2e");
|
||||||
|
await page.getByRole("button", { name: "Schlüssel erzeugen" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name })).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page).toHaveURL(/\/schluessel\/[^/]+$/);
|
||||||
|
await expect(page.getByText("verschlüsselt gespeichert")).toBeVisible();
|
||||||
|
await expect(page.getByText("ed25519", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByLabel("Public Key", { exact: true })).toContainText("ssh-ed25519 AAAA");
|
||||||
|
await expect(page.getByLabel("Public Key", { exact: true })).toContainText(
|
||||||
|
`sshkv:${name.toLowerCase().replace(/ /g, "-")}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto(`/schluessel?q=${encodeURIComponent(name)}`);
|
||||||
|
await expect(page.getByRole("link", { name })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Public Key importieren, Duplikat wird erkannt", async ({ page }) => {
|
||||||
|
const name = `${eindeutig()} Laptop`;
|
||||||
|
const paar = await schluesselpaarErzeugen("ED25519", { kommentar: "e2e@laptop" });
|
||||||
|
await oidcAnmelden(page, "operator");
|
||||||
|
await page.goto("/schluessel/neu");
|
||||||
|
await page.getByRole("radio", { name: "Importieren" }).click();
|
||||||
|
await page.getByLabel("Zweck").fill(name);
|
||||||
|
await page.getByLabel("Public Key").fill(paar.publicKeyZeile);
|
||||||
|
await page.locator(`input[name="kategorie"][value="PERSON"]`).check();
|
||||||
|
await page.getByRole("button", { name: "Schlüssel importieren" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name })).toBeVisible();
|
||||||
|
await expect(page.getByText("nicht gespeichert (Personen-Key)")).toBeVisible();
|
||||||
|
await expect(page.getByText(paar.fingerprint)).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto("/schluessel/neu");
|
||||||
|
await page.getByRole("radio", { name: "Importieren" }).click();
|
||||||
|
await page.getByLabel("Zweck").fill(`${name} nochmal`);
|
||||||
|
await page.getByLabel("Public Key").fill(paar.publicKeyZeile);
|
||||||
|
await page.getByRole("button", { name: "Schlüssel importieren" }).click();
|
||||||
|
await expect(page.getByRole("alert").first()).toContainText(`bereits verwaltet: „${name}“`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Leser sieht Schlüssel, darf aber keine anlegen", async ({ page }) => {
|
||||||
|
await oidcAnmelden(page, "reader");
|
||||||
|
await page.goto("/schluessel");
|
||||||
|
await expect(page.getByRole("heading", { name: "Schlüssel", exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole("link", { name: "Schlüssel anlegen" })).toHaveCount(0);
|
||||||
|
await page.goto("/schluessel/neu");
|
||||||
|
await expect(page).toHaveURL(/\/kein-zugriff/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { WARTESCHLANGE, warteschlangeHolen, type VorgangsJob } from "@/server/vorgaenge/warteschlange";
|
||||||
|
import { vorgangAusfuehren } from "@/worker/ausfuehren";
|
||||||
|
import { HANDLER } from "@/worker/handler";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuehrt einen eingereihten Vorgang so aus, wie es der Worker tut (#012): Job aus pg-boss
|
||||||
|
* holen, mit seinem `geheim` ausfuehren, danach loeschen (src/worker/index.ts).
|
||||||
|
* Liefert zusaetzlich die rohen Job-Daten, damit Tests pruefen koennen, was darin stand.
|
||||||
|
*/
|
||||||
|
export async function vorgangImWorker(vorgangId: string): Promise<{ jobDaten: VorgangsJob }> {
|
||||||
|
const boss = await warteschlangeHolen();
|
||||||
|
// Die Warteschlange ueberlebt das Leeren der Tabellen; deshalb gezielt den eigenen Job suchen.
|
||||||
|
const jobs = await boss.fetch<VorgangsJob>(WARTESCHLANGE, { batchSize: 100 });
|
||||||
|
const job = jobs.find((kandidat) => kandidat.data.vorgangId === vorgangId);
|
||||||
|
if (!job) throw new Error(`Kein Job für Vorgang ${vorgangId}`);
|
||||||
|
await vorgangAusfuehren(job.data.vorgangId, HANDLER, job.data.geheim);
|
||||||
|
await boss.complete(WARTESCHLANGE, job.id);
|
||||||
|
if (job.data.geheim) await boss.deleteJob(WARTESCHLANGE, job.id);
|
||||||
|
return { jobDaten: job.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Zeilen aller Tabellen als ein Text – fuer „steht das Geheimnis irgendwo?“. */
|
||||||
|
export async function datenbankAlsText(): Promise<string> {
|
||||||
|
const teile = await Promise.all([
|
||||||
|
prisma.vorgang.findMany(),
|
||||||
|
prisma.protokoll.findMany(),
|
||||||
|
prisma.$queryRaw<unknown[]>`SELECT data FROM pgboss.job`,
|
||||||
|
]);
|
||||||
|
return JSON.stringify(teile, (_k, wert: unknown) => (typeof wert === "bigint" ? wert.toString() : wert));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function warteschlangeStoppen(): Promise<void> {
|
||||||
|
const boss = await warteschlangeHolen();
|
||||||
|
await boss.stop({ graceful: false });
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { SCHLUESSEL_DETAIL_SELECT, SCHLUESSEL_LISTE_SELECT } from "@/server/queries/schluessel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strukturell (#012): Jede Prisma-Abfrage auf `Schluessel` ausserhalb des Workers hat ein
|
||||||
|
* festes `select`. Ohne `select` liefert Prisma die ganze Zeile – samt Chiffrat –, ohne dass
|
||||||
|
* der Feldname im Code steht; tests/geheimnisse-grenze.test.ts saehe das nicht.
|
||||||
|
*/
|
||||||
|
function dateien(verzeichnis: string): string[] {
|
||||||
|
return readdirSync(verzeichnis).flatMap((name) => {
|
||||||
|
const pfad = path.join(verzeichnis, name);
|
||||||
|
if (statSync(pfad).isDirectory()) return name === "generated" ? [] : dateien(pfad);
|
||||||
|
return /\.(ts|tsx)$/.test(name) ? [pfad.split(path.sep).join("/")] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Worker darf (und muss) die ganze Zeile sehen; die Grenze dort zieht geheimnisse.ts. */
|
||||||
|
const WEB = dateien("src").filter((d) => !d.startsWith("src/worker/"));
|
||||||
|
|
||||||
|
/** Methoden, die Zeilen zurueckgeben – nur die brauchen ein `select`. */
|
||||||
|
const ZEILEN =
|
||||||
|
/\.schluessel\.(findMany|findFirst|findFirstOrThrow|findUnique|findUniqueOrThrow|create|update|upsert|delete)\(/g;
|
||||||
|
|
||||||
|
/** Argumenttext eines Aufrufs ab der oeffnenden Klammer, per Klammerzaehlung. */
|
||||||
|
function argumente(text: string, start: number): string {
|
||||||
|
let tiefe = 0;
|
||||||
|
for (let i = start; i < text.length; i++) {
|
||||||
|
if (text[i] === "(") tiefe++;
|
||||||
|
else if (text[i] === ")" && --tiefe === 0) return text.slice(start, i + 1);
|
||||||
|
}
|
||||||
|
return text.slice(start);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Abfragen auf Schluessel im Web", () => {
|
||||||
|
it("findet Aufrufe", () => {
|
||||||
|
const anzahl = WEB.reduce((summe, d) => summe + [...readFileSync(d, "utf8").matchAll(ZEILEN)].length, 0);
|
||||||
|
expect(anzahl).toBeGreaterThan(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("haben alle ein select", () => {
|
||||||
|
const verstoesse: string[] = [];
|
||||||
|
for (const datei of WEB) {
|
||||||
|
const text = readFileSync(datei, "utf8");
|
||||||
|
for (const treffer of text.matchAll(ZEILEN)) {
|
||||||
|
const start = treffer.index + treffer[0].length - 1;
|
||||||
|
if (!/\bselect\s*:/.test(argumente(text, start))) {
|
||||||
|
verstoesse.push(`${datei}: ${treffer[0]}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(verstoesse).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holen Schluessel nie als ganze Relation (include/select: { schluessel: true })", () => {
|
||||||
|
// Zeilen mit `_count` zaehlen nur und sind erlaubt.
|
||||||
|
const verstoesse = WEB.filter((d) =>
|
||||||
|
readFileSync(d, "utf8")
|
||||||
|
.split("\n")
|
||||||
|
.some(
|
||||||
|
(zeile) =>
|
||||||
|
!zeile.includes("_count") &&
|
||||||
|
/\b(schluessel|vorgaenger|nachfolger|deploymentSchluessel|erstellteSchluessel)\s*:\s*true/.test(
|
||||||
|
zeile,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(verstoesse).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("die festen select-Listen nennen kein Chiffrat", () => {
|
||||||
|
for (const auswahl of [SCHLUESSEL_LISTE_SELECT, SCHLUESSEL_DETAIL_SELECT]) {
|
||||||
|
expect(Object.keys(auswahl)).not.toContain("privatVerschluesselt");
|
||||||
|
expect(Object.keys(auswahl)).not.toContain("dekVerschluesselt");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { entschluesseln } from "@/lib/geheimnisse";
|
||||||
|
import {
|
||||||
|
ablaufZustand,
|
||||||
|
fingerprintKurz,
|
||||||
|
freierSlug,
|
||||||
|
slugAus,
|
||||||
|
typAnzeige,
|
||||||
|
erzeugenSchema,
|
||||||
|
} from "@/lib/schluessel";
|
||||||
|
import { privateKeyLesen, schluesselpaarErzeugen } from "@/lib/ssh/openssh";
|
||||||
|
import { schluesselDetail, schluesselListe } from "@/server/queries/schluessel";
|
||||||
|
import { FachFehler } from "@/server/services/fachfehler";
|
||||||
|
import {
|
||||||
|
erzeugenAnfordern,
|
||||||
|
paarImportAnfordern,
|
||||||
|
publicKeyImportieren,
|
||||||
|
schluesselBearbeiten,
|
||||||
|
} from "@/server/services/schluessel";
|
||||||
|
import { masterKeyVergessen, privateKeyLaden } from "@/worker/geheimnisse";
|
||||||
|
import { datenbankLeeren } from "./hilfen/db";
|
||||||
|
import { datenbankAlsText, vorgangImWorker, warteschlangeStoppen } from "./hilfen/vorgang";
|
||||||
|
import { ed25519Blob, rsaBlob, zeile } from "./ssh/hilfen";
|
||||||
|
|
||||||
|
/** Schluessel erzeugen, importieren, bearbeiten (#012) – Service, Worker-Ablauf, Lesemodell. */
|
||||||
|
|
||||||
|
let verzeichnis: string;
|
||||||
|
let akteur: { id: string; name: string };
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
verzeichnis = mkdtempSync(path.join(tmpdir(), "sshkv-master-"));
|
||||||
|
const datei = path.join(verzeichnis, "master.key");
|
||||||
|
writeFileSync(datei, randomBytes(32), { mode: 0o600 });
|
||||||
|
process.env["SSHKV_MASTER_KEY_FILE"] = datei;
|
||||||
|
masterKeyVergessen();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
rmSync(verzeichnis, { recursive: true, force: true });
|
||||||
|
await warteschlangeStoppen();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
const benutzer = await prisma.benutzer.create({ data: { name: "Olga Operator", rolle: "OPERATOR" } });
|
||||||
|
akteur = { id: benutzer.id, name: benutzer.name };
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fachfehlerVon(versuch: Promise<unknown>): Promise<FachFehler> {
|
||||||
|
const fehler = await versuch.then(
|
||||||
|
() => undefined,
|
||||||
|
(f: unknown) => f,
|
||||||
|
);
|
||||||
|
expect(fehler).toBeInstanceOf(FachFehler);
|
||||||
|
return fehler as FachFehler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kern eines Private Keys (ohne Kopf- und Fusszeile) – das eigentliche Geheimnis. */
|
||||||
|
function kern(privateKey: string): string {
|
||||||
|
return privateKey.split("\n").slice(1, -2).join("").slice(0, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Regeln (rein)", () => {
|
||||||
|
it.each([
|
||||||
|
["Backup srv-backup01 → srv-pve01", "backup-srv-backup01-srv-pve01"],
|
||||||
|
["Größter Schlüssel für Ärzte", "groesster-schluessel-fuer-aerzte"],
|
||||||
|
[" --- ", "schluessel"],
|
||||||
|
["Café", "cafe"],
|
||||||
|
["x".repeat(80), "x".repeat(64)],
|
||||||
|
])("slugAus(%j) = %j", (name, slug) => {
|
||||||
|
expect(slugAus(name)).toBe(slug);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("haengt Nummern an, bis der Slug frei ist", async () => {
|
||||||
|
const belegt = new Set(["backup", "backup-2"]);
|
||||||
|
expect(await freierSlug("backup", async (s) => !belegt.has(s))).toBe("backup-3");
|
||||||
|
const lang = "a".repeat(64);
|
||||||
|
const ergebnis = await freierSlug(lang, async (s) => s !== lang);
|
||||||
|
expect(ergebnis).toHaveLength(64);
|
||||||
|
expect(ergebnis.endsWith("-2")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kuerzt Fingerprints und zeigt Typen", () => {
|
||||||
|
expect(fingerprintKurz("SHA256:q3vN8xP0b4Tz7KfL2mWcR9yHdE1sJ6uAoGi5VnBQe0k")).toBe(
|
||||||
|
"SHA256:q3vN8xP0…Qe0k",
|
||||||
|
);
|
||||||
|
expect(typAnzeige("ED25519", "ssh-ed25519 AAAA")).toBe("ed25519");
|
||||||
|
expect(typAnzeige("RSA4096", "ssh-rsa AAAA")).toBe("rsa-4096");
|
||||||
|
expect(typAnzeige("ANDERER", "ecdsa-sha2-nistp256 AAAA")).toBe("ecdsa-sha2-nistp256");
|
||||||
|
expect(typAnzeige("ANDERER", "sk-ssh-ed25519@openssh.com AAAA")).toBe("sk-ssh-ed25519");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bewertet das Ablaufdatum", () => {
|
||||||
|
const jetzt = new Date("2026-10-12T08:00:00Z");
|
||||||
|
expect(ablaufZustand(null, jetzt).art).toBe("keiner");
|
||||||
|
expect(ablaufZustand(new Date("2026-10-30T00:00:00Z"), jetzt)).toEqual({ art: "bald", tage: 18 });
|
||||||
|
expect(ablaufZustand(new Date("2026-11-12T00:00:00Z"), jetzt).art).toBe("spaeter");
|
||||||
|
expect(ablaufZustand(new Date("2026-10-11T00:00:00Z"), jetzt).art).toBe("abgelaufen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prueft Eingaben", () => {
|
||||||
|
expect(erzeugenSchema.safeParse({ name: "" }).success).toBe(false);
|
||||||
|
expect(erzeugenSchema.safeParse({ name: "x", slug: "Mit Leer" }).success).toBe(false);
|
||||||
|
expect(erzeugenSchema.safeParse({ name: "x", kommentar: "a\nb" }).success).toBe(false);
|
||||||
|
expect(erzeugenSchema.safeParse({ name: "x", ablaufAm: "2026-02-30" }).success).toBe(false);
|
||||||
|
expect(erzeugenSchema.safeParse({ name: "x", typ: "DSA" }).success).toBe(false);
|
||||||
|
const ok = erzeugenSchema.parse({ name: " Backup ", tags: "PVE, pve backup", ablaufAm: "2030-01-31" });
|
||||||
|
expect(ok).toMatchObject({ name: "Backup", tags: ["pve", "backup"], typ: "ED25519", slug: null });
|
||||||
|
expect(ok.ablaufAm?.toISOString()).toBe("2030-01-31T00:00:00.000Z");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Public Key importieren", () => {
|
||||||
|
it("legt an, setzt Slug und Kommentar und protokolliert", async () => {
|
||||||
|
const blob = ed25519Blob();
|
||||||
|
const neu = await publicKeyImportieren(
|
||||||
|
{
|
||||||
|
name: "Laptop Florian",
|
||||||
|
kategorie: "PERSON",
|
||||||
|
publicKey: zeile("ssh-ed25519", blob, "florian@laptop"),
|
||||||
|
},
|
||||||
|
akteur,
|
||||||
|
);
|
||||||
|
const s = await schluesselDetail(neu.id);
|
||||||
|
expect(s).toMatchObject({
|
||||||
|
slug: "laptop-florian",
|
||||||
|
typ: "ED25519",
|
||||||
|
kategorie: "PERSON",
|
||||||
|
kommentar: "florian@laptop",
|
||||||
|
privatGespeichert: false,
|
||||||
|
selbstHinterlegt: false,
|
||||||
|
});
|
||||||
|
expect(s?.publicKey).toBe(zeile("ssh-ed25519", blob, "florian@laptop"));
|
||||||
|
const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "schluessel.importiert" } });
|
||||||
|
expect(eintrag.fingerprint).toBe(s?.fingerprint);
|
||||||
|
expect(eintrag.akteurName).toBe("Olga Operator");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("meldet einen schon verwalteten Key mit Verweis", async () => {
|
||||||
|
const z = zeile("ssh-ed25519", ed25519Blob());
|
||||||
|
const erster = await publicKeyImportieren({ name: "Erster", publicKey: z }, akteur);
|
||||||
|
const fehler = await fachfehlerVon(publicKeyImportieren({ name: "Zweiter", publicKey: z }, akteur));
|
||||||
|
expect(fehler.message).toContain("„Erster“");
|
||||||
|
expect(fehler.message).toContain(`/schluessel/${erster.id}`);
|
||||||
|
expect(fehler.feldfehler["publicKey"]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nummeriert abgeleitete Slugs und lehnt vergebene Wunsch-Slugs ab", async () => {
|
||||||
|
const a = await publicKeyImportieren(
|
||||||
|
{ name: "Backup", publicKey: zeile("ssh-ed25519", ed25519Blob()) },
|
||||||
|
akteur,
|
||||||
|
);
|
||||||
|
const b = await publicKeyImportieren(
|
||||||
|
{ name: "Backup", publicKey: zeile("ssh-ed25519", ed25519Blob()) },
|
||||||
|
akteur,
|
||||||
|
);
|
||||||
|
expect([a.slug, b.slug]).toEqual(["backup", "backup-2"]);
|
||||||
|
const fehler = await fachfehlerVon(
|
||||||
|
publicKeyImportieren(
|
||||||
|
{ name: "X", slug: "backup", publicKey: zeile("ssh-ed25519", ed25519Blob()) },
|
||||||
|
akteur,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(fehler.feldfehler["slug"]).toMatch(/vergeben/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verlangt RSA mit mindestens 2048 Bit und erkennt RSA-4096", async () => {
|
||||||
|
const fehler = await fachfehlerVon(
|
||||||
|
publicKeyImportieren({ name: "Alt", publicKey: zeile("ssh-rsa", rsaBlob(1024)) }, akteur),
|
||||||
|
);
|
||||||
|
expect(fehler.feldfehler["publicKey"]).toMatch(/2048/);
|
||||||
|
const neu = await publicKeyImportieren(
|
||||||
|
{ name: "Neu", publicKey: zeile("ssh-rsa", rsaBlob(4096)) },
|
||||||
|
akteur,
|
||||||
|
);
|
||||||
|
expect((await schluesselDetail(neu.id))?.typ).toBe("RSA4096");
|
||||||
|
const mittel = await publicKeyImportieren(
|
||||||
|
{ name: "Mittel", publicKey: zeile("ssh-rsa", rsaBlob(3072)) },
|
||||||
|
akteur,
|
||||||
|
);
|
||||||
|
expect((await schluesselDetail(mittel.id))?.typ).toBe("ANDERER");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lehnt Unsinn, Vergangenheit und unbekannte Bezuege ab", async () => {
|
||||||
|
await fachfehlerVon(publicKeyImportieren({ name: "X", publicKey: "kein key" }, akteur));
|
||||||
|
const vergangen = await fachfehlerVon(
|
||||||
|
publicKeyImportieren(
|
||||||
|
{ name: "X", publicKey: zeile("ssh-ed25519", ed25519Blob()), ablaufAm: "2020-01-01" },
|
||||||
|
akteur,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(vergangen.feldfehler["ablaufAm"]).toBeDefined();
|
||||||
|
const person = await fachfehlerVon(
|
||||||
|
publicKeyImportieren(
|
||||||
|
{ name: "X", publicKey: zeile("ssh-ed25519", ed25519Blob()), personId: "gibtsnicht" },
|
||||||
|
akteur,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(person.feldfehler["personId"]).toBeDefined();
|
||||||
|
expect(await prisma.schluessel.count()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setzt das Ablaufdatum aus der Laufzeit der Vorlage", async () => {
|
||||||
|
const vorlage = await prisma.vorlage.create({
|
||||||
|
data: { name: "Kurz", zielbenutzer: "deploy", standardLaufzeitTage: 10 },
|
||||||
|
});
|
||||||
|
const neu = await publicKeyImportieren(
|
||||||
|
{ name: "Mit Vorlage", publicKey: zeile("ssh-ed25519", ed25519Blob()), vorlageId: vorlage.id },
|
||||||
|
akteur,
|
||||||
|
);
|
||||||
|
const s = await schluesselDetail(neu.id);
|
||||||
|
expect(ablaufZustand(s!.ablaufAm)).toEqual({ art: "bald", tage: 10 });
|
||||||
|
expect(s?.vorlage?.name).toBe("Kurz");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Erzeugen im Worker", () => {
|
||||||
|
it("erzeugt ed25519, versiegelt und verweist den Vorgang auf den Schluessel", async () => {
|
||||||
|
const vorgangId = await erzeugenAnfordern(
|
||||||
|
{ name: "Backup srv-backup01", tags: "backup" },
|
||||||
|
undefined,
|
||||||
|
akteur,
|
||||||
|
);
|
||||||
|
const { jobDaten } = await vorgangImWorker(vorgangId);
|
||||||
|
expect(jobDaten.geheim).toBeUndefined();
|
||||||
|
|
||||||
|
const vorgang = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } });
|
||||||
|
expect(vorgang.status).toBe("ERLEDIGT");
|
||||||
|
const { schluesselId, fingerprint } = vorgang.ergebnis as { schluesselId: string; fingerprint: string };
|
||||||
|
expect(vorgang.schluesselId).toBe(schluesselId);
|
||||||
|
|
||||||
|
const s = await schluesselDetail(schluesselId);
|
||||||
|
expect(s).toMatchObject({
|
||||||
|
slug: "backup-srv-backup01",
|
||||||
|
kommentar: "sshkv:backup-srv-backup01",
|
||||||
|
kategorie: "DIENST",
|
||||||
|
typ: "ED25519",
|
||||||
|
privatGespeichert: true,
|
||||||
|
passphraseGeschuetzt: false,
|
||||||
|
fingerprint,
|
||||||
|
tags: ["backup"],
|
||||||
|
});
|
||||||
|
expect(s?.erstelltVon?.name).toBe("Olga Operator");
|
||||||
|
|
||||||
|
const privat = await privateKeyLaden(schluesselId);
|
||||||
|
expect(privateKeyLesen(privat.toString("utf8")).fingerprint).toBe(fingerprint);
|
||||||
|
privat.fill(0);
|
||||||
|
|
||||||
|
const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "schluessel.erzeugt" } });
|
||||||
|
expect(eintrag).toMatchObject({ fingerprint, akteurId: akteur.id, ergebnis: "ERFOLG" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("speichert mit Passphrase den geschuetzten Key – die Passphrase nirgends", async () => {
|
||||||
|
const passphrase = "korrekt-pferd-batterie-heftklammer";
|
||||||
|
const vorgangId = await erzeugenAnfordern({ name: "Geschuetzt", typ: "ED25519" }, passphrase, akteur);
|
||||||
|
const vorgang = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } });
|
||||||
|
expect(JSON.stringify(vorgang.eingabe)).not.toContain(passphrase);
|
||||||
|
expect(vorgang.eingabe).toMatchObject({ mitPassphrase: true });
|
||||||
|
|
||||||
|
const { jobDaten } = await vorgangImWorker(vorgangId);
|
||||||
|
expect(jobDaten.geheim).toBeDefined();
|
||||||
|
expect(jobDaten.geheim).not.toContain(passphrase);
|
||||||
|
expect(JSON.parse(entschluesseln(jobDaten.geheim!))).toEqual({ passphrase });
|
||||||
|
|
||||||
|
const { schluesselId } = (await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } }))
|
||||||
|
.ergebnis as { schluesselId: string };
|
||||||
|
expect((await schluesselDetail(schluesselId))?.passphraseGeschuetzt).toBe(true);
|
||||||
|
const privat = (await privateKeyLaden(schluesselId)).toString("utf8");
|
||||||
|
expect(() => privateKeyLesen(privat)).toThrow(/Passphrase/);
|
||||||
|
expect(privateKeyLesen(privat, passphrase).verschluesselt).toBe(true);
|
||||||
|
|
||||||
|
const alles = await datenbankAlsText();
|
||||||
|
expect(alles).not.toContain(passphrase);
|
||||||
|
expect(alles).not.toContain("PRIVATE KEY");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("erzeugt RSA-4096", { timeout: 30_000 }, async () => {
|
||||||
|
const vorgangId = await erzeugenAnfordern({ name: "Alt-System", typ: "RSA4096" }, undefined, akteur);
|
||||||
|
await vorgangImWorker(vorgangId);
|
||||||
|
const { schluesselId } = (await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } }))
|
||||||
|
.ergebnis as { schluesselId: string };
|
||||||
|
const s = await schluesselDetail(schluesselId);
|
||||||
|
expect(s?.typ).toBe("RSA4096");
|
||||||
|
expect(s?.publicKey.startsWith("ssh-rsa ")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scheitert verstaendlich, wenn der Slug inzwischen vergeben ist", async () => {
|
||||||
|
const vorgangId = await erzeugenAnfordern({ name: "Doppelt" }, undefined, akteur);
|
||||||
|
await publicKeyImportieren(
|
||||||
|
{ name: "Doppelt", slug: "doppelt", publicKey: zeile("ssh-ed25519", ed25519Blob()) },
|
||||||
|
akteur,
|
||||||
|
);
|
||||||
|
await vorgangImWorker(vorgangId);
|
||||||
|
const vorgang = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } });
|
||||||
|
expect(vorgang.status).toBe("FEHLGESCHLAGEN");
|
||||||
|
expect(vorgang.fehler).toMatch(/inzwischen vergeben/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Paar importieren im Worker", () => {
|
||||||
|
it("prueft, versiegelt und laesst weder Vorgang noch Protokoll den Key sehen", async () => {
|
||||||
|
const paar = await schluesselpaarErzeugen("ED25519", { kommentar: "alt@server" });
|
||||||
|
const vorgangId = await paarImportAnfordern(
|
||||||
|
{ name: "Altlast" },
|
||||||
|
paar.privateKeyOpenssh,
|
||||||
|
undefined,
|
||||||
|
akteur,
|
||||||
|
);
|
||||||
|
const vorgang = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } });
|
||||||
|
expect(JSON.stringify(vorgang)).not.toContain("PRIVATE KEY");
|
||||||
|
|
||||||
|
const { jobDaten } = await vorgangImWorker(vorgangId);
|
||||||
|
expect(jobDaten.geheim).not.toContain(kern(paar.privateKeyOpenssh));
|
||||||
|
const fertig = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } });
|
||||||
|
expect(fertig.status).toBe("ERLEDIGT");
|
||||||
|
const { schluesselId } = fertig.ergebnis as { schluesselId: string };
|
||||||
|
const s = await schluesselDetail(schluesselId);
|
||||||
|
expect(s).toMatchObject({
|
||||||
|
fingerprint: paar.fingerprint,
|
||||||
|
kommentar: "alt@server",
|
||||||
|
privatGespeichert: true,
|
||||||
|
});
|
||||||
|
expect((await privateKeyLaden(schluesselId)).toString("utf8")).toBe(paar.privateKeyOpenssh);
|
||||||
|
|
||||||
|
const alles = await datenbankAlsText();
|
||||||
|
expect(alles).not.toContain("PRIVATE KEY");
|
||||||
|
expect(alles).not.toContain(kern(paar.privateKeyOpenssh));
|
||||||
|
expect(await prisma.protokoll.count({ where: { aktion: "schluessel.importiert" } })).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("behaelt einen passphrasengeschuetzten Key so, wie er kam", async () => {
|
||||||
|
const passphrase = "nur-zur-pruefung-1234";
|
||||||
|
const paar = await schluesselpaarErzeugen("ED25519", { kommentar: "x", passphrase });
|
||||||
|
const ohne = await paarImportAnfordern({ name: "Ohne" }, paar.privateKeyOpenssh, undefined, akteur);
|
||||||
|
await vorgangImWorker(ohne);
|
||||||
|
expect((await prisma.vorgang.findUniqueOrThrow({ where: { id: ohne } })).fehler).toMatch(/Passphrase/);
|
||||||
|
|
||||||
|
const falsch = await paarImportAnfordern(
|
||||||
|
{ name: "Falsch" },
|
||||||
|
paar.privateKeyOpenssh,
|
||||||
|
"Zz9-unpassend",
|
||||||
|
akteur,
|
||||||
|
);
|
||||||
|
await vorgangImWorker(falsch);
|
||||||
|
const fehler = (await prisma.vorgang.findUniqueOrThrow({ where: { id: falsch } })).fehler ?? "";
|
||||||
|
expect(fehler).toMatch(/Passphrase/);
|
||||||
|
expect(fehler).not.toContain("Zz9-unpassend");
|
||||||
|
|
||||||
|
const richtig = await paarImportAnfordern(
|
||||||
|
{ name: "Richtig" },
|
||||||
|
paar.privateKeyOpenssh,
|
||||||
|
passphrase,
|
||||||
|
akteur,
|
||||||
|
);
|
||||||
|
await vorgangImWorker(richtig);
|
||||||
|
const { schluesselId } = (await prisma.vorgang.findUniqueOrThrow({ where: { id: richtig } }))
|
||||||
|
.ergebnis as {
|
||||||
|
schluesselId: string;
|
||||||
|
};
|
||||||
|
expect((await schluesselDetail(schluesselId))?.passphraseGeschuetzt).toBe(true);
|
||||||
|
expect((await privateKeyLaden(schluesselId)).toString("utf8")).toBe(paar.privateKeyOpenssh);
|
||||||
|
expect(await datenbankAlsText()).not.toContain(passphrase);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lehnt Fremdformate im Web und Duplikate im Worker ab", async () => {
|
||||||
|
const pem = "-----BEGIN RSA PRIVATE KEY-----\nMIIE\n-----END RSA PRIVATE KEY-----";
|
||||||
|
const fehler = await fachfehlerVon(paarImportAnfordern({ name: "PEM" }, pem, undefined, akteur));
|
||||||
|
expect(fehler.message).not.toContain("MIIE");
|
||||||
|
|
||||||
|
const paar = await schluesselpaarErzeugen("ED25519", { kommentar: "x" });
|
||||||
|
await publicKeyImportieren({ name: "Schon da", publicKey: paar.publicKeyZeile }, akteur);
|
||||||
|
const id = await paarImportAnfordern({ name: "Nochmal" }, paar.privateKeyOpenssh, undefined, akteur);
|
||||||
|
await vorgangImWorker(id);
|
||||||
|
expect((await prisma.vorgang.findUniqueOrThrow({ where: { id } })).fehler).toMatch(/„Schon da“/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Bearbeiten und Lesemodell", () => {
|
||||||
|
it("aendert Metadaten, protokolliert nur Geaendertes und schuetzt den Status", async () => {
|
||||||
|
const neu = await publicKeyImportieren(
|
||||||
|
{ name: "Alt", publicKey: zeile("ssh-ed25519", ed25519Blob()) },
|
||||||
|
akteur,
|
||||||
|
);
|
||||||
|
await schluesselBearbeiten(neu.id, { name: "Neu", tags: "a b", status: "ABGELAUFEN" }, akteur);
|
||||||
|
const s = await schluesselDetail(neu.id);
|
||||||
|
expect(s).toMatchObject({ name: "Neu", tags: ["a", "b"], status: "ABGELAUFEN", slug: "alt" });
|
||||||
|
const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "schluessel.geaendert" } });
|
||||||
|
expect(Object.keys(eintrag.details as object).sort()).toEqual(["name", "status", "tags"]);
|
||||||
|
|
||||||
|
await prisma.schluessel.update({
|
||||||
|
where: { id: neu.id },
|
||||||
|
data: { status: "WIDERRUFEN" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
await fachfehlerVon(schluesselBearbeiten(neu.id, { name: "Neu", status: "AKTIV" }, akteur));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filtert die Liste und liefert keine Chiffrat-Felder", async () => {
|
||||||
|
await publicKeyImportieren(
|
||||||
|
{
|
||||||
|
name: "Person A",
|
||||||
|
kategorie: "PERSON",
|
||||||
|
publicKey: zeile("ssh-ed25519", ed25519Blob()),
|
||||||
|
tags: "laptop",
|
||||||
|
},
|
||||||
|
akteur,
|
||||||
|
);
|
||||||
|
const vorgangId = await erzeugenAnfordern({ name: "Dienst B" }, undefined, akteur);
|
||||||
|
await vorgangImWorker(vorgangId);
|
||||||
|
|
||||||
|
expect((await schluesselListe()).map((s) => s.name)).toEqual(["Dienst B", "Person A"]);
|
||||||
|
expect((await schluesselListe({ kategorie: "PERSON" })).map((s) => s.name)).toEqual(["Person A"]);
|
||||||
|
expect((await schluesselListe({ q: "laptop" })).map((s) => s.name)).toEqual(["Person A"]);
|
||||||
|
expect((await schluesselListe({ status: "WIDERRUFEN" })).length).toBe(0);
|
||||||
|
expect((await schluesselListe({ kategorie: "QUATSCH" })).length).toBe(2);
|
||||||
|
|
||||||
|
const liste = await schluesselListe();
|
||||||
|
const dienst = liste.find((s) => s.name === "Dienst B")!;
|
||||||
|
expect(dienst.privatGespeichert).toBe(true);
|
||||||
|
const detail = await schluesselDetail(dienst.id);
|
||||||
|
for (const antwort of [liste, detail]) {
|
||||||
|
const text = JSON.stringify(antwort);
|
||||||
|
expect(text).not.toMatch(/privatVerschluesselt|dekVerschluesselt|masterKeyVersion/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user