Merge branch 'main' into phase/029-handbuch

This commit is contained in:
2026-10-12 16:49:27 +02:00
21 changed files with 2263 additions and 16 deletions
+193 -9
View File
@@ -1,18 +1,202 @@
import { Download, ScrollText, Search } from "lucide-react";
import type { Metadata } from "next";
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
import { KettenPruefung } from "@/components/protokoll/KettenPruefung";
import { ProtokollListe } from "@/components/protokoll/ProtokollListe";
import { Button, buttonKlassen } 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 { AKTIONSGRUPPEN } from "@/lib/protokoll-aktionen";
import {
cursorAus,
ERGEBNIS_TEXT,
protokollFilterAktiv,
protokollFilterAus,
protokollFilterQuery,
} from "@/lib/protokoll-filter";
import { syslogZielText } from "@/lib/syslog";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { protokollAkteure, protokollSeite, protokollZaehlen } from "@/server/queries/protokoll";
import { ketteZwischengespeichert, syslogKonfig } from "@/server/services/protokoll";
export const metadata: Metadata = { title: "Audit-Log" };
export default async function Protokoll() {
type Suchparameter = Promise<Record<string, string | string[] | undefined>>;
/**
* Audit-Log (#027): jede Rolle darf lesen. Filter per GET, damit Links, „ältere laden“ und
* der CSV-Export denselben Ausschnitt meinen. Es gibt hier bewusst keinen Knopf, der einen
* Eintrag aendert oder loescht.
*/
export default async function Protokoll({ searchParams }: { searchParams: Suchparameter }) {
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
await sitzungErforderlich();
await sitzungErforderlich("lesen", "/protokoll");
const parameter = await searchParams;
const filter = protokollFilterAus(parameter);
const vor = cursorAus(parameter["vor"]);
const [seite, zaehler, akteure] = await Promise.all([
protokollSeite(filter, vor),
protokollZaehlen(filter),
protokollAkteure(),
]);
const gefiltert = protokollFilterAktiv(filter);
const query = protokollFilterQuery(filter);
const kette = ketteZwischengespeichert();
const syslog = syslogKonfig();
return (
<Platzhalterseite
titel="Audit-Log"
untertitel="Lückenloses, verkettetes Protokoll aller Aktionen"
phase={5}
issue="#027"
/>
<div className="flex min-w-0 flex-col gap-6 sm:gap-7">
<Seitenkopf
titel="Audit-Log"
untertitel="Lückenloses, verkettetes Protokoll aller Aktionen – nur lesbar, nicht änderbar"
aktionen={
<a href={`/api/protokoll.csv${query}`} download className={buttonKlassen("sekundaer")}>
<Download className="size-4" aria-hidden />
CSV exportieren
</a>
}
/>
<Karte innen="ohne" className="divide-border divide-y">
<div className="flex flex-wrap items-baseline gap-x-6 gap-y-1 px-5 py-4 sm:px-6">
<p className="text-[14px]">
<span className="font-display text-text text-[22px] font-semibold tabular-nums">
{zaehler.gesamt.toLocaleString("de-DE")}
</span>{" "}
<span className="text-muted">Einträge insgesamt</span>
</p>
{gefiltert && (
<p className="text-muted text-[14px]">
<span className="text-text font-medium tabular-nums">
{zaehler.treffer.toLocaleString("de-DE")}
</span>{" "}
passen zum Filter
</p>
)}
<p className="text-muted text-[13px] sm:ml-auto">
Syslog:{" "}
{syslog ? (
<span className="text-text font-mono">{syslogZielText(syslog)}</span>
) : (
<span>nicht eingerichtet</span>
)}
</p>
</div>
<div className="px-5 py-4 sm:px-6">
<KettenPruefung
anfang={
kette
? {
fehlerhafteId: kette.fehlerhafteId,
eintraege: kette.eintraege,
geprueftAm: kette.geprueftAm.toISOString(),
}
: null
}
/>
</div>
</Karte>
<form method="get" role="search" className="grid grid-cols-2 items-end gap-3 lg:grid-cols-4">
<Feld
beschriftung="Suche"
name="q"
type="search"
defaultValue={filter.q}
placeholder="Fingerprint, Host oder Benutzer"
autoComplete="off"
spellCheck={false}
className="col-span-2"
/>
<Feld
beschriftung="Host"
name="host"
defaultValue={filter.host}
placeholder="z. B. srv-pve01"
autoComplete="off"
spellCheck={false}
mono
className="col-span-2 sm:col-span-1"
/>
<Auswahlliste
beschriftung="Akteur"
name="akteur"
defaultValue={filter.akteur}
className="col-span-2 sm:col-span-1"
>
<option value="">Alle</option>
{/* Ein gefilterter Name, der nicht mehr unter den haeufigsten ist, bleibt waehlbar. */}
{[...new Set([...akteure, ...(filter.akteur ? [filter.akteur] : [])])].map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</Auswahlliste>
<Auswahlliste beschriftung="Aktion" name="gruppe" defaultValue={filter.gruppe}>
<option value="">Alle Aktionen</option>
{Object.entries(AKTIONSGRUPPEN).map(([wert, text]) => (
<option key={wert} value={wert}>
{text}
</option>
))}
</Auswahlliste>
<Auswahlliste beschriftung="Ergebnis" name="ergebnis" defaultValue={filter.ergebnis}>
<option value="">Alle</option>
{Object.entries(ERGEBNIS_TEXT).map(([wert, text]) => (
<option key={wert} value={wert}>
{text}
</option>
))}
</Auswahlliste>
<Feld beschriftung="Von" name="von" type="date" defaultValue={filter.von} />
<Feld beschriftung="Bis" name="bis" type="date" defaultValue={filter.bis} />
<div className="col-span-2 flex gap-2.5 sm:justify-end lg:col-span-4">
<Button type="submit" icon={<Search className="size-4" />} className="flex-1 sm:flex-none">
Filtern
</Button>
{gefiltert && (
<Button href="/protokoll" className="flex-1 sm:flex-none">
Zurücksetzen
</Button>
)}
</div>
</form>
{vor !== undefined && (
<p className="text-muted text-[14px]">
Einträge älter als #{String(vor)}.{" "}
<a href={`/protokoll${query}`} className="text-accent underline-offset-2 hover:underline">
Zu den neuesten
</a>
</p>
)}
{seite.zeilen.length === 0 ? (
gefiltert ? (
<Leerzustand
icon={<Search className="size-5" />}
titel="Keine Treffer"
text="Kein Eintrag passt zu Suche und Filter."
aktion={<Button href="/protokoll">Filter zurücksetzen</Button>}
/>
) : (
<Leerzustand
icon={<ScrollText className="size-5" />}
titel="Noch keine Einträge"
text="Sobald jemand sich anmeldet oder etwas ändert, steht es hier."
/>
)
) : (
<Karte>
{/* key: neue Filter oder ein neuer Cursor beginnen eine frische Liste. */}
<ProtokollListe
key={`${query}|${String(vor ?? "")}`}
seite={seite}
query={query.replace(/^\?/, "")}
/>
</Karte>
)}
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import { NextResponse } from "next/server";
import { CSV_BOM, csvZeile } from "@/lib/csv";
import { PROTOKOLL_CSV_KOPF, protokollCsvWerte, protokollFilterAus } from "@/lib/protokoll-filter";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { protokollStapelweise } from "@/server/queries/protokoll";
import { protokollieren } from "@/server/services/protokoll";
/**
* Protokoll als CSV (#027) mit denselben Filtern wie die Seite. Jede Rolle darf lesen; der
* Handler prueft das selbst.
*
* Gestreamt: die Datenbank liefert Stapel zu 500 Zeilen, jeder Stapel geht sofort hinaus.
* Ein Protokoll mit Hunderttausenden Eintraegen muss so nie ganz in den Speicher.
*/
export async function GET(anfrage: Request) {
let benutzer;
try {
({ benutzer } = await rechtErforderlich("lesen"));
} catch (fehler) {
if (fehler instanceof RechteFehler) return NextResponse.json({ fehler: fehler.message }, { status: 403 });
throw fehler;
}
const filter = protokollFilterAus(Object.fromEntries(new URL(anfrage.url).searchParams));
// Vor dem ersten Byte: wer das Protokoll mitnimmt, steht selbst darin – und scheitert das
// Schreiben, gibt es auch keinen Export.
await protokollieren({
akteur: { id: benutzer.id, name: benutzer.name },
aktion: "protokoll.exportiert",
ergebnis: "ERFOLG",
details: { filter: Object.fromEntries(Object.entries(filter).filter(([, wert]) => wert)) },
});
const kodierer = new TextEncoder();
const stapel = protokollStapelweise(filter);
const strom = new ReadableStream<Uint8Array>({
start(steuerung) {
steuerung.enqueue(kodierer.encode(CSV_BOM + csvZeile(PROTOKOLL_CSV_KOPF) + "\r\n"));
},
// pull statt Schleife in start: der naechste Stapel wird erst gelesen, wenn der Client
// den vorigen abgenommen hat (Gegendruck).
async pull(steuerung) {
try {
const { value, done } = await stapel.next();
if (done) {
steuerung.close();
return;
}
steuerung.enqueue(
kodierer.encode(value.map((z) => csvZeile(protokollCsvWerte(z)) + "\r\n").join("")),
);
} catch (fehler) {
steuerung.error(fehler);
}
},
async cancel() {
await stapel.return(undefined);
},
});
const tag = new Date().toISOString().slice(0, 10);
return new NextResponse(strom, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="sshkv-protokoll-${tag}.csv"`,
"Cache-Control": "no-store",
},
});
}
@@ -0,0 +1,81 @@
"use client";
import { ShieldAlert, ShieldCheck, ShieldQuestion } from "lucide-react";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/Button";
import { uhrzeit } from "@/lib/format";
import { kettePruefenAktion, type KettenAntwort } from "@/server/actions/protokoll";
/**
* Integritaet der Hash-Kette (#027). Geprueft wird nur auf Knopfdruck – die Pruefung liest
* jede Zeile; ein Ergebnis der letzten 5 Minuten bringt die Seite schon mit.
*/
export function KettenPruefung({ anfang }: { anfang: KettenAntwort | null }) {
const [ergebnis, setErgebnis] = useState<KettenAntwort | null>(anfang);
const [laeuft, starten] = useTransition();
const pruefen = () =>
starten(async () => {
setErgebnis(await kettePruefenAktion());
});
return (
<div
className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
aria-live="polite"
>
<Status ergebnis={ergebnis} />
<Button onClick={pruefen} disabled={laeuft} klein className="self-start sm:self-auto">
{laeuft ? "Prüft …" : "Kette prüfen"}
</Button>
</div>
);
}
function Status({ ergebnis }: { ergebnis: KettenAntwort | null }) {
if (!ergebnis) {
return (
<p className="text-muted flex items-center gap-2 text-[14px]">
<ShieldQuestion aria-hidden className="size-4 shrink-0" />
Hash-Kette noch nicht geprüft
</p>
);
}
if (ergebnis.fehler) {
return <p className="text-danger text-[14px]">{ergebnis.fehler}</p>;
}
const wann = ergebnis.geprueftAm ? ` um ${uhrzeit(ergebnis.geprueftAm)} Uhr` : "";
if (!ergebnis.fehlerhafteId) {
return (
<p className="text-ok flex items-center gap-2 text-[14px] font-medium">
<ShieldCheck aria-hidden className="size-4 shrink-0" />
<span>
Hash-Kette unversehrt
<span className="text-muted font-normal">
{" "}
· {ergebnis.eintraege?.toLocaleString("de-DE")} Einträge geprüft{wann}
</span>
</span>
</p>
);
}
const id = ergebnis.fehlerhafteId;
// Die Seite beginnt beim fehlerhaften Eintrag, der Anker hebt ihn hervor.
const ziel = `/protokoll?vor=${(BigInt(id) + 1n).toString()}#eintrag-${id}`;
return (
<div
role="alert"
className="border-danger-border bg-danger-soft text-danger rounded-box flex min-w-0 items-start gap-2.5 border px-3 py-2.5 text-[14px]"
>
<ShieldAlert aria-hidden className="mt-0.5 size-4 shrink-0" />
<p className="min-w-0">
<strong className="font-semibold">Hash-Kette beschädigt.</strong> Der Eintrag{" "}
<a href={ziel} className="font-mono underline underline-offset-2">
#{id}
</a>{" "}
stimmt nicht mit seinem Hash oder Vorgänger überein – ab hier ist das Protokoll nicht mehr belegbar.
Vergleichen Sie mit der Syslog-Kopie und sichern Sie die Datenbank.{wann && ` Geprüft${wann}.`}
</p>
</div>
);
}
+246
View File
@@ -0,0 +1,246 @@
"use client";
import { clsx } from "clsx";
import { ChevronDown, History } from "lucide-react";
import { useState, useTransition } from "react";
import { Badge, type BadgeVariante } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { DiffBox } from "@/components/ui/DiffBox";
import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
import { aktionText, aktionTon, type AktionsTon } from "@/lib/protokoll-aktionen";
import { diffZeilen, ERGEBNIS_TEXT, protokollZeit } from "@/lib/protokoll-filter";
import { fingerprintKurz } from "@/lib/schluessel";
import type { ProtokollSeite, ProtokollZeile } from "@/server/queries/protokoll";
import { aeltereLadenAktion } from "@/server/actions/protokoll";
const TON_FARBE: Record<AktionsTon, string> = { ok: "text-ok", gefahr: "text-danger", neutral: "text-text" };
const ERGEBNIS_BADGE: Record<ProtokollZeile["ergebnis"], BadgeVariante> = {
ERFOLG: "neutral",
FEHLER: "gefahr",
VERWEIGERT: "gefahr",
};
/**
* Protokolleintraege als Tabelle (ab md) bzw. Karten (darunter), jede Zeile aufklappbar
* (#027). „Ältere laden“ haengt die naechste Seite an, statt die Liste zu ersetzen – wer
* eine Spur verfolgt, will das Neuere dabei nicht aus dem Blick verlieren.
*/
export function ProtokollListe({ seite, query }: { seite: ProtokollSeite; query: string }) {
const [zeilen, setZeilen] = useState(seite.zeilen);
const [weiterVor, setWeiterVor] = useState(seite.weiterVor);
const [offen, setOffen] = useState<ReadonlySet<string>>(new Set());
const [fehler, setFehler] = useState<string>();
const [laedt, starten] = useTransition();
const umschalten = (id: string) =>
setOffen((vorher) => {
const neu = new Set(vorher);
if (neu.has(id)) neu.delete(id);
else neu.add(id);
return neu;
});
const aeltereLaden = () => {
if (!weiterVor) return;
starten(async () => {
const antwort = await aeltereLadenAktion(query, weiterVor);
if ("fehler" in antwort) {
setFehler(antwort.fehler);
return;
}
setFehler(undefined);
setZeilen((vorher) => [...vorher, ...antwort.zeilen]);
setWeiterVor(antwort.weiterVor);
});
};
return (
<div className="flex min-w-0 flex-col gap-4">
{/* Unter md Karten statt der Tabelle: acht Spalten passen nicht auf 390 px. */}
<ul className="flex flex-col md:hidden" aria-label="Protokolleinträge">
{zeilen.map((z) => (
<li key={z.id} className="border-border border-b py-3 last:border-b-0">
<ZeileKompakt z={z} />
<details className="group mt-1">
<summary className="text-muted hover:text-text -mx-2 flex min-h-11 cursor-pointer list-none items-center gap-1.5 rounded-box px-2 text-[13px]">
<ChevronDown aria-hidden className="size-4 transition-transform group-open:rotate-180" />
Einzelheiten
</summary>
<Einzelheiten z={z} />
</details>
</li>
))}
</ul>
<Tabelle
className="hidden md:block"
beschriftung="Protokolleinträge"
spalten={[
{ titel: "Zeitpunkt" },
{ titel: "Aktion" },
{ titel: "Akteur" },
{ titel: "Host" },
{ titel: "Zielbenutzer" },
{ titel: "Fingerprint" },
{ titel: "Ergebnis" },
{ titel: "Einzelheiten", versteckt: true, className: "w-12" },
]}
>
{zeilen.map((z) => {
const istOffen = offen.has(z.id);
return <ZeilenPaar key={z.id} z={z} offen={istOffen} umschalten={() => umschalten(z.id)} />;
})}
</Tabelle>
{fehler && <p className="text-danger text-[13px]">{fehler}</p>}
{weiterVor && (
<div className="flex justify-center">
<Button onClick={aeltereLaden} disabled={laedt} icon={<History className="size-4" />}>
{laedt ? "Lädt …" : "Ältere laden"}
</Button>
</div>
)}
</div>
);
}
function AktionZelle({ z }: { z: ProtokollZeile }) {
return (
<span title={z.aktion} className={clsx("font-medium", TON_FARBE[aktionTon(z.aktion, z.ergebnis)])}>
{aktionText(z.aktion)}
</span>
);
}
function ErgebnisBadge({ ergebnis }: { ergebnis: ProtokollZeile["ergebnis"] }) {
return <Badge variante={ERGEBNIS_BADGE[ergebnis]}>{ERGEBNIS_TEXT[ergebnis]}</Badge>;
}
function ZeilenPaar({ z, offen, umschalten }: { z: ProtokollZeile; offen: boolean; umschalten: () => void }) {
const detailId = `eintrag-${z.id}-einzelheiten`;
return (
<>
<TabellenZeile className={clsx(offen && "border-b-0")}>
<TabellenZelle mono className="text-muted whitespace-nowrap">
{/* Anker fuer den Sprung aus der Kettenpruefung zur ersten fehlerhaften ID. */}
<span
id={`eintrag-${z.id}`}
className="target:text-danger target:outline-danger scroll-mt-24 target:outline target:outline-offset-4"
>
{protokollZeit(z.zeitpunkt)}
</span>
</TabellenZelle>
<TabellenZelle className="min-w-[12rem]">
<AktionZelle z={z} />
</TabellenZelle>
<TabellenZelle className="whitespace-nowrap">{z.akteurName}</TabellenZelle>
<TabellenZelle mono className="whitespace-nowrap">
{z.hostName ?? <Leer />}
</TabellenZelle>
<TabellenZelle mono className="whitespace-nowrap">
{z.zielbenutzer ?? <Leer />}
</TabellenZelle>
<TabellenZelle mono className="text-muted whitespace-nowrap">
{z.fingerprint ? <span title={z.fingerprint}>{fingerprintKurz(z.fingerprint)}</span> : <Leer />}
</TabellenZelle>
<TabellenZelle className="whitespace-nowrap">
<ErgebnisBadge ergebnis={z.ergebnis} />
</TabellenZelle>
<TabellenZelle rechts className="w-12">
<button
type="button"
onClick={umschalten}
aria-expanded={offen}
aria-controls={detailId}
aria-label={`Einzelheiten zu Eintrag ${z.id}`}
className="text-muted hover:text-text hover:bg-raised rounded-feld -my-2 inline-flex size-11 items-center justify-center transition-colors"
>
<ChevronDown aria-hidden className={clsx("size-4 transition-transform", offen && "rotate-180")} />
</button>
</TabellenZelle>
</TabellenZeile>
{offen && (
<tr id={detailId} className="border-border border-b last:border-b-0">
<td colSpan={8} className="pb-4">
<Einzelheiten z={z} />
</td>
</tr>
)}
</>
);
}
function Leer() {
return <span className="text-muted">–</span>;
}
/** Kopf einer Karte in der mobilen Ansicht. */
function ZeileKompakt({ z }: { z: ProtokollZeile }) {
return (
<div className="flex min-w-0 flex-col gap-1.5">
<div className="flex items-center justify-between gap-3">
<span className="text-muted font-mono text-[12.5px]">{protokollZeit(z.zeitpunkt)}</span>
<ErgebnisBadge ergebnis={z.ergebnis} />
</div>
<span className="text-[14.5px] leading-snug">
<AktionZelle z={z} />
</span>
<span className="text-muted text-[13px] break-words">{z.akteurName}</span>
{(z.hostName || z.zielbenutzer) && (
<span className="text-text font-mono text-[12.5px] break-all">
{z.zielbenutzer ? `${z.zielbenutzer}@` : ""}
{z.hostName ?? "–"}
</span>
)}
{z.fingerprint && (
<span className="text-muted font-mono text-[12.5px] break-all">{fingerprintKurz(z.fingerprint)}</span>
)}
</div>
);
}
function Einzelheiten({ z }: { z: ProtokollZeile }) {
return (
<div className="bg-sidebar border-border rounded-box flex min-w-0 flex-col gap-4 border p-4">
<dl className="grid min-w-0 gap-x-4 gap-y-2 text-[13px] sm:grid-cols-[9rem_minmax(0,1fr)]">
<dt className="text-muted">ID</dt>
<dd className="font-mono">{z.id}</dd>
<dt className="text-muted">Aktion (technisch)</dt>
<dd className="font-mono break-all">{z.aktion}</dd>
{z.fingerprint && (
<>
<dt className="text-muted">Fingerprint</dt>
<dd className="font-mono break-all">{z.fingerprint}</dd>
</>
)}
<dt className="text-muted">Hash</dt>
<dd className="font-mono break-all" data-feld="hash">
{z.hash}
</dd>
<dt className="text-muted">Vorgänger-Hash</dt>
<dd className="font-mono break-all">{z.vorgaengerHash || "– (erster Eintrag)"}</dd>
</dl>
{z.diff && (
<section className="flex min-w-0 flex-col gap-2" aria-label="Diff">
<h3 className="text-muted text-[13px] font-medium">Diff</h3>
<DiffBox zeilen={diffZeilen(z.diff)} />
</section>
)}
{z.details && (
<section className="flex min-w-0 flex-col gap-2" aria-label="Details">
<h3 className="text-muted text-[13px] font-medium">Details</h3>
<dl className="border-border bg-bg rounded-box grid min-w-0 gap-x-4 gap-y-1.5 border p-3 text-[12.5px] sm:grid-cols-[minmax(0,12rem)_minmax(0,1fr)]">
{z.details.map((d) => (
<div key={d.name} className="contents">
<dt className="text-muted font-mono break-all">{d.name}</dt>
<dd className="text-text mb-1.5 font-mono break-all whitespace-pre-wrap sm:mb-0">{d.wert}</dd>
</div>
))}
</dl>
</section>
)}
</div>
);
}
+137
View File
@@ -0,0 +1,137 @@
/**
* Lesbare Beschriftung der technischen Protokoll-Aktionen (#027).
*
* Das Protokoll speichert stabile Schluessel wie `authorized_keys.geschrieben`, damit sich
* Auswertungen (CSV, Syslog, SIEM) nicht an Oberflaechentexten aufhaengen. Die Ansicht
* uebersetzt sie hier. `tests/protokoll-aktionen.test.ts` sucht jede `aktion: "…"` im
* Quellcode und verlangt einen Eintrag – eine neue Aktion ohne Text faellt so sofort auf.
*
* `aendert`: Die Aktion veraendert etwas (Datei geschrieben, Schluessel erzeugt …) und wird bei
* Erfolg in --ok gezeigt. Lesende oder rein informative Aktionen (Anmeldung, Abruf, Export)
* bleiben neutral, damit das Grün die Stellen markiert, an denen sich etwas getan hat.
*/
export const AKTIONSGRUPPEN = {
anmeldung: "Anmeldung",
hostkey: "Host-Key",
ausrollen: "Ausrollen",
schluessel: "Schlüssel",
widerruf: "Widerruf/Rotation",
zielkonto: "Zielkonto",
abgleich: "Abgleich",
hosts: "Hosts",
stammdaten: "Personen und Vorlagen",
protokoll: "Protokoll",
} as const;
export type Aktionsgruppe = keyof typeof AKTIONSGRUPPEN;
type Beschreibung = { readonly text: string; readonly gruppe: Aktionsgruppe; readonly aendert: boolean };
const a = (text: string, gruppe: Aktionsgruppe, aendert = true): Beschreibung => ({ text, gruppe, aendert });
export const AKTIONEN: Readonly<Record<string, Beschreibung>> = {
// Anmeldung und Bestaetigung (#004)
"anmeldung.oidc": a("Anmeldung (OIDC)", "anmeldung", false),
"anmeldung.oidc.erstmals": a("Erste Anmeldung (OIDC)", "anmeldung"),
"anmeldung.verweigert": a("Anmeldung verweigert", "anmeldung", false),
"anmeldung.notfall": a("Anmeldung Notfall-Admin", "anmeldung", false),
"anmeldung.notfall.fehlgeschlagen": a("Notfall-Anmeldung fehlgeschlagen", "anmeldung", false),
"anmeldung.notfall.gesperrt": a("Notfall-Anmeldung gesperrt", "anmeldung", false),
"anmeldung.notfall.totp_falsch": a("Notfall-Anmeldung: TOTP falsch", "anmeldung", false),
"bestaetigung.oidc": a("Anmeldung bestätigt (Re-Auth)", "anmeldung", false),
"notfall.totp_eingerichtet": a("TOTP für Notfall-Admin eingerichtet", "anmeldung"),
// Host-Keys (#009)
"hostkey.abgerufen": a("Host-Key abgerufen", "hostkey", false),
"hostkey.bestaetigt": a("Host-Key bestätigt", "hostkey"),
"hostkey.ersetzt": a("Host-Key ersetzt", "hostkey"),
"hostkey.abgelehnt": a("Host-Key abgelehnt", "hostkey"),
"hostkey.abweichung": a("Host-Key weicht ab – Verbindung abgebrochen", "hostkey", false),
// Ausrollen (#016, #017)
"authorized_keys.geschrieben": a("authorized_keys geschrieben", "ausrollen"),
"privatekey.ausgerollt": a("Private Key ausgerollt", "ausrollen"),
"verwendung.public.angelegt": a("Public Key zugewiesen", "ausrollen"),
"verwendung.public.entfernen": a("Public-Key-Zuweisung zum Entfernen vorgemerkt", "ausrollen"),
"verwendung.private.angelegt": a("Private Key zugewiesen", "ausrollen"),
"verwendung.private.entfernen": a("Private-Key-Zuweisung zum Entfernen vorgemerkt", "ausrollen"),
// Schluessel (#012, #013, #014)
"schluessel.erzeugt": a("Schlüssel erzeugt", "schluessel"),
"schluessel.importiert": a("Schlüssel importiert", "schluessel"),
"schluessel.uebernommen": a("Schlüssel vom Host übernommen", "schluessel"),
"schluessel.selbst_hinterlegt": a("Eigenen Public Key hinterlegt", "schluessel"),
"schluessel.selbst_entfernt": a("Eigenen Public Key entfernt", "schluessel"),
"schluessel.geaendert": a("Schlüssel geändert", "schluessel"),
"privatekey.angefordert": a("Private Key angefordert", "schluessel", false),
"privatekey.angezeigt": a("Private Key angezeigt", "schluessel", false),
"privatekey.heruntergeladen": a("Private Key heruntergeladen", "schluessel", false),
"privatekey.geloescht": a("Private Key gelöscht", "schluessel"),
"deploymentkey.erzeugt": a("Deployment-Key erzeugt", "schluessel"),
// Widerruf und Rotation (#023, #024)
"schluessel.widerruf.eingeleitet": a("Widerruf eingeleitet", "widerruf"),
"schluessel.widerrufen": a("Schlüssel widerrufen", "widerruf"),
"schluessel.rotiert": a("Schlüssel rotiert", "widerruf"),
"schluessel.rotation.uebernommen": a("Rotation: Zuweisungen übernommen", "widerruf"),
// Zielkonten (#018)
"zielkonto.angelegt": a("Zielkonto angelegt", "zielkonto"),
"zielkonto.geaendert": a("Zielkonto geändert", "zielkonto"),
"zielkonto.entfernt": a("Zielkonto entfernt", "zielkonto"),
// Abgleich und Uebernahme (#021, #022)
abgleich: a("Abgleich durchgeführt", "abgleich", false),
"uebernahme.verwalten": a("Fremden Key übernommen und verwaltet", "abgleich"),
"uebernahme.bekannt": a("Fremden Key als bekannt aufgenommen", "abgleich"),
// Hosts, Gruppen, Kunden (#008, #010)
"host.angelegt": a("Host angelegt", "hosts"),
"host.geaendert": a("Host geändert", "hosts"),
"host.archiviert": a("Host archiviert", "hosts"),
"host.bootstrap.angefordert": a("Einrichtung angefordert", "hosts", false),
"host.bootstrap": a("Host eingerichtet (Bootstrap)", "hosts"),
"host.bootstrap.geprueft": a("Einrichtung geprüft", "hosts"),
"hostgruppe.angelegt": a("Hostgruppe angelegt", "hosts"),
"hostgruppe.geaendert": a("Hostgruppe geändert", "hosts"),
"kunde.angelegt": a("Kunde angelegt", "hosts"),
verbindungstest: a("Verbindung geprüft", "hosts", false),
// Personen und Vorlagen (#013, #019)
"person.angelegt": a("Person angelegt", "stammdaten"),
"person.geaendert": a("Person geändert", "stammdaten"),
"person.archiviert": a("Person archiviert", "stammdaten"),
"person.verknuepft": a("Person mit Konto verknüpft", "stammdaten"),
"vorlage.angelegt": a("Vorlage angelegt", "stammdaten"),
"vorlage.geaendert": a("Vorlage geändert", "stammdaten"),
"vorlage.archiviert": a("Vorlage archiviert", "stammdaten"),
// Protokoll selbst (#027)
"protokoll.exportiert": a("Protokoll exportiert (CSV)", "protokoll", false),
"protokoll.kette_beschaedigt": a("Hash-Kette beschädigt gefunden", "protokoll", false),
};
/** Text zur Aktion; unbekannte (etwa aus aelteren Versionen) bleiben technisch lesbar. */
export function aktionText(aktion: string): string {
return AKTIONEN[aktion]?.text ?? aktion;
}
/** Alle bekannten Aktionen einer Gruppe – Grundlage fuer den Filter `aktion IN (…)`. */
export function aktionenDerGruppe(gruppe: Aktionsgruppe): string[] {
return Object.entries(AKTIONEN)
.filter(([, b]) => b.gruppe === gruppe)
.map(([aktion]) => aktion);
}
export type AktionsTon = "ok" | "gefahr" | "neutral";
/** Farbe in der Liste: Fehlschlag und Verweigerung immer --danger, Aenderungen --ok. */
export function aktionTon(aktion: string, ergebnis: "ERFOLG" | "FEHLER" | "VERWEIGERT"): AktionsTon {
if (ergebnis !== "ERFOLG") return "gefahr";
return AKTIONEN[aktion]?.aendert ? "ok" : "neutral";
}
export function istAktionsgruppe(wert: string): wert is Aktionsgruppe {
return Object.hasOwn(AKTIONSGRUPPEN, wert);
}
+211
View File
@@ -0,0 +1,211 @@
import { istAktionsgruppe, type Aktionsgruppe } from "@/lib/protokoll-aktionen";
import { ZEITZONE } from "@/lib/format";
/**
* Filter der Protokollansicht (#027). Framework-frei: Seite, CSV-Route und Server Action
* lesen dieselben GET-Parameter, damit der Export genau den Ausschnitt der Seite liefert.
*/
export const PROTOKOLL_ERGEBNISSE = ["ERFOLG", "FEHLER", "VERWEIGERT"] as const;
export type ProtokollErgebnisWert = (typeof PROTOKOLL_ERGEBNISSE)[number];
export const ERGEBNIS_TEXT: Record<ProtokollErgebnisWert, string> = {
ERFOLG: "Erfolg",
FEHLER: "Fehler",
VERWEIGERT: "Verweigert",
};
export type ProtokollFilter = {
/** Tag im Format JJJJ-MM-TT (Europe/Berlin), einschliesslich. */
readonly von: string;
readonly bis: string;
readonly gruppe: Aktionsgruppe | "";
readonly akteur: string;
readonly host: string;
readonly ergebnis: ProtokollErgebnisWert | "";
readonly q: string;
};
export const LEERER_FILTER: ProtokollFilter = {
von: "",
bis: "",
gruppe: "",
akteur: "",
host: "",
ergebnis: "",
q: "",
};
const TAG = /^\d{4}-\d{2}-\d{2}$/;
function gueltigerTag(wert: string): string {
if (!TAG.test(wert)) return "";
return Number.isNaN(new Date(`${wert}T00:00:00Z`).getTime()) ? "" : wert;
}
export function protokollFilterAus(
parameter: Record<string, string | string[] | undefined>,
): ProtokollFilter {
const einzeln = (name: string) => {
const wert = parameter[name];
return ((Array.isArray(wert) ? wert[0] : wert) ?? "").trim().slice(0, 200);
};
const gruppe = einzeln("gruppe");
const ergebnis = einzeln("ergebnis");
return {
von: gueltigerTag(einzeln("von")),
bis: gueltigerTag(einzeln("bis")),
gruppe: istAktionsgruppe(gruppe) ? gruppe : "",
akteur: einzeln("akteur"),
host: einzeln("host"),
ergebnis: (PROTOKOLL_ERGEBNISSE as readonly string[]).includes(ergebnis)
? (ergebnis as ProtokollErgebnisWert)
: "",
q: einzeln("q"),
};
}
export function protokollFilterAktiv(filter: ProtokollFilter): boolean {
return Object.values(filter).some(Boolean);
}
/** Dieselben Filter als Query-String (CSV-Link, „ältere laden“, Anker auf eine ID). */
export function protokollFilterQuery(filter: ProtokollFilter, extra: Record<string, string> = {}): string {
const p = new URLSearchParams();
for (const [name, wert] of Object.entries(filter)) if (wert) p.set(name, wert);
for (const [name, wert] of Object.entries(extra)) p.set(name, wert);
const text = p.toString();
return text ? `?${text}` : "";
}
/** Cursor „älter als ID“; nur positive Ganzzahlen, sonst keiner. */
export function cursorAus(wert: string | string[] | undefined): bigint | undefined {
const text = Array.isArray(wert) ? wert[0] : wert;
if (!text || !/^\d{1,19}$/.test(text)) return undefined;
const zahl = BigInt(text);
return zahl > 0n ? zahl : undefined;
}
/** Abstand Europe/Berlin zu UTC in Millisekunden zu einem Zeitpunkt (Sommer-/Winterzeit). */
function berlinVersatz(zeitpunkt: Date): number {
const teile = new Intl.DateTimeFormat("en-US", {
timeZone: ZEITZONE,
hourCycle: "h23",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).formatToParts(zeitpunkt);
const wert = (typ: string) => Number(teile.find((t) => t.type === typ)?.value ?? 0);
const alsUtc = Date.UTC(
wert("year"),
wert("month") - 1,
wert("day"),
wert("hour"),
wert("minute"),
wert("second"),
);
return alsUtc - Math.floor(zeitpunkt.getTime() / 1000) * 1000;
}
/**
* Mitternacht des Tages in Berlin als Zeitpunkt. Die Oberflaeche zeigt Berliner Zeit (#002),
* also muss „von 12.10.“ auch Berliner Mitternacht meinen – nicht UTC.
*/
export function berlinTagesbeginn(tag: string): Date {
const utcMitternacht = new Date(`${tag}T00:00:00Z`);
const erst = new Date(utcMitternacht.getTime() - berlinVersatz(utcMitternacht));
// Zweiter Schritt fuer Tage, an denen die Uhr umgestellt wird.
return new Date(utcMitternacht.getTime() - berlinVersatz(erst));
}
/** Zeitraum als halboffenes Intervall [ab, vor) – „bis“ schliesst den ganzen Tag ein. */
export function zeitraum(filter: Pick<ProtokollFilter, "von" | "bis">): { ab?: Date; vor?: Date } {
const ergebnis: { ab?: Date; vor?: Date } = {};
if (filter.von) ergebnis.ab = berlinTagesbeginn(filter.von);
if (filter.bis) {
const folgetag = new Date(`${filter.bis}T00:00:00Z`);
folgetag.setUTCDate(folgetag.getUTCDate() + 1);
ergebnis.vor = berlinTagesbeginn(folgetag.toISOString().slice(0, 10));
}
return ergebnis;
}
/** „12.10.2026 08:42:17“ – Sekunden gehoeren ins Protokoll, Komma nicht (#027). */
const ZEITSTEMPEL = new Intl.DateTimeFormat("de-DE", {
timeZone: ZEITZONE,
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
});
export function protokollZeit(wert: Date | string): string {
return ZEITSTEMPEL.format(new Date(wert)).replace(",", "");
}
export type ProtokollDiffZeile = { art: "hinzu" | "entfernt" | "kontext"; text: string };
/**
* Der gespeicherte Diff ist Text mit `+ `/`- ` je Zeile (#016); Private-Key-Ausrollen schreibt
* nur „anlegen ~/.ssh/…“ – solche Zeilen bleiben Kontext.
*/
export function diffZeilen(diff: string): ProtokollDiffZeile[] {
return diff.split("\n").map((zeile) => {
if (zeile.startsWith("+")) return { art: "hinzu", text: zeile.replace(/^\+ ?/, "") };
if (zeile.startsWith("-")) return { art: "entfernt", text: zeile.replace(/^- ?/, "") };
return { art: "kontext", text: zeile };
});
}
/** Spalten des CSV-Exports (#027): technisch, damit Auswertungen stabil bleiben. */
export const PROTOKOLL_CSV_KOPF = [
"id",
"zeitpunkt",
"akteur",
"aktion",
"ergebnis",
"host",
"zielbenutzer",
"fingerprint",
"diff",
"details",
"vorgaengerHash",
"hash",
] as const;
export type ProtokollCsvQuelle = {
readonly id: bigint | string;
readonly zeitpunkt: Date;
readonly akteurName: string;
readonly aktion: string;
readonly ergebnis: string;
readonly hostName: string | null;
readonly zielbenutzer: string | null;
readonly fingerprint: string | null;
readonly diff: string | null;
readonly details: unknown;
readonly vorgaengerHash: string;
readonly hash: string;
};
export function protokollCsvWerte(z: ProtokollCsvQuelle): (string | null)[] {
return [
String(z.id),
z.zeitpunkt.toISOString(),
z.akteurName,
z.aktion,
z.ergebnis,
z.hostName,
z.zielbenutzer,
z.fingerprint,
z.diff,
z.details == null ? null : JSON.stringify(z.details),
z.vorgaengerHash,
z.hash,
];
}
+255
View File
@@ -0,0 +1,255 @@
import { createSocket, type Socket as UdpSocket } from "node:dgram";
import { hostname } from "node:os";
import { connect, type Socket as TcpSocket } from "node:net";
/**
* Weiterleitung des Protokolls an Syslog (#027), RFC 5424 mit strukturierten Daten.
*
* Bewusst klein und ohne Abhaengigkeit: ein paar Zeilen Format, ein UDP- oder TCP-Socket.
* Die Kopie beim Syslog-Server ist der zweite, vom sshkv-Host unabhaengige Beleg – wer die
* Datenbank uebernimmt, kann die Hash-Kette zwar neu rechnen, aber nicht zurueckholen, was
* schon beim Syslog liegt (PLAN §3).
*
* Es gehen nur die Felder hinaus, die das Protokoll ohnehin geschwaerzt speichert und die
* Uebersicht zeigt – `details` und `diff` nie, dort koennten Pfade und Dateiinhalte stehen.
*/
export type SyslogProtokoll = "udp" | "tcp";
export type SyslogKonfig = {
readonly host: string;
readonly port: number;
readonly protokoll: SyslogProtokoll;
readonly appName: string;
/** Facility-Nummer nach RFC 5424 (authpriv = 10). */
readonly facility: number;
};
export const FACILITIES: Readonly<Record<string, number>> = {
kern: 0,
user: 1,
daemon: 3,
auth: 4,
syslog: 5,
authpriv: 10,
local0: 16,
local1: 17,
local2: 18,
local3: 19,
local4: 20,
local5: 21,
local6: 22,
local7: 23,
};
/** Severity aus dem Ergebnis: Erfolg ist bemerkenswert, Verweigerung eine Warnung. */
export const SEVERITY = { ERFOLG: 5, VERWEIGERT: 4, FEHLER: 3 } as const;
export type SyslogEintrag = {
readonly id?: bigint | string | null;
readonly zeitpunkt: Date;
readonly akteur: string;
readonly aktion: string;
readonly ergebnis: keyof typeof SEVERITY;
readonly hostName?: string | null;
readonly zielbenutzer?: string | null;
readonly fingerprint?: string | null;
readonly hash?: string | null;
};
/** IANA-Private-Enterprise-Number fuer Beispiele/Eigenbau ist 32473 (RFC 5612). */
export const SD_ID = "sshkv@32473";
/** RFC 5426 verlangt mindestens 480, empfiehlt 2048 Byte; 2048 passt auch in UDP sicher. */
export const HOECHSTLAENGE = 2048;
/** Je SD-Wert in Byte vor dem Escapen; acht Werte passen so mit Kopf in 2048 Byte. */
const WERT_HOECHSTBYTES = 120;
/**
* Liest die Konfiguration aus der Umgebung. Ohne `SSHKV_SYSLOG_HOST` ist Syslog aus; Fehler
* in den uebrigen Werten fallen auf die Standards zurueck, statt den Start zu verhindern.
*/
export function syslogKonfigAus(umgebung: Record<string, string | undefined>): SyslogKonfig | null {
const host = umgebung["SSHKV_SYSLOG_HOST"]?.trim();
if (!host) return null;
const port = Number(umgebung["SSHKV_SYSLOG_PORT"] ?? 514);
const protokoll = umgebung["SSHKV_SYSLOG_PROTOKOLL"]?.trim().toLowerCase() === "tcp" ? "tcp" : "udp";
const appName = asciiDruckbar(umgebung["SSHKV_SYSLOG_APPNAME"]?.trim() || "sshkv", 48) || "sshkv";
const facilityName = umgebung["SSHKV_SYSLOG_FACILITY"]?.trim().toLowerCase() || "authpriv";
return {
host,
port: Number.isInteger(port) && port > 0 && port < 65536 ? port : 514,
protokoll,
appName,
facility: FACILITIES[facilityName] ?? FACILITIES["authpriv"]!,
};
}
/** Kopffelder duerfen nur druckbares ASCII ohne Leerzeichen enthalten (RFC 5424 §6). */
function asciiDruckbar(wert: string, laenge: number): string {
return wert.replace(/[^\x21-\x7e]/g, "_").slice(0, laenge);
}
/** Werte in SD-PARAM: `"`, `\` und `]` mit Backslash (RFC 5424 §6.3.3). */
export function sdWertEscapen(wert: string): string {
return wert.replace(/["\\\]]/g, (zeichen) => `\\${zeichen}`);
}
/** Kuerzt vor dem Escapen, damit kein Backslash ohne sein Zeichen am Ende steht. */
function sdWert(wert: string | null | undefined, bytes: number): string {
// Steuerzeichen (Zeilenumbrueche) wuerden manche Empfaenger zum Zeilenende verleiten.
const sauber = (wert ?? "").replace(/[\x00-\x1f\x7f]/g, " ");
return sdWertEscapen(textKuerzen(sauber, bytes));
}
/** Kuerzt auf hoechstens `bytes` UTF-8-Bytes, ohne ein Zeichen zu zerschneiden. */
export function textKuerzen(text: string, bytes: number): string {
if (Buffer.byteLength(text, "utf8") <= bytes) return text;
let ergebnis = "";
let laenge = 0;
for (const zeichen of text) {
const groesse = Buffer.byteLength(zeichen, "utf8");
if (laenge + groesse + 3 > bytes) break;
ergebnis += zeichen;
laenge += groesse;
}
return `${ergebnis}...`;
}
/** Baut eine vollstaendige RFC-5424-Zeile (ohne TCP-Rahmen). */
export function syslogNachricht(
eintrag: SyslogEintrag,
konfig: Pick<SyslogKonfig, "appName" | "facility">,
optionen: { rechner?: string; prozess?: number; text?: string } = {},
): string {
const pri = konfig.facility * 8 + SEVERITY[eintrag.ergebnis];
const rechner = asciiDruckbar(optionen.rechner ?? hostname(), 255) || "-";
const prozess = String(optionen.prozess ?? process.pid);
const msgId = asciiDruckbar(eintrag.aktion, 32) || "-";
const parameter: [string, string | null | undefined][] = [
["id", eintrag.id == null ? null : String(eintrag.id)],
["akteur", eintrag.akteur],
["aktion", eintrag.aktion],
["ergebnis", eintrag.ergebnis],
["host", eintrag.hostName],
["zielbenutzer", eintrag.zielbenutzer],
["fingerprint", eintrag.fingerprint],
["hash", eintrag.hash],
];
const kopfMit = (wertBytes: number) => {
const sd = `[${SD_ID}${parameter
.filter(([, wert]) => wert != null && wert !== "")
.map(([name, wert]) => ` ${name}="${sdWert(wert, wertBytes)}"`)
.join("")}]`;
return `<${pri}>1 ${eintrag.zeitpunkt.toISOString()} ${rechner} ${konfig.appName} ${prozess} ${msgId} ${sd}`;
};
let kopf = kopfMit(WERT_HOECHSTBYTES);
// Nur bei absichtlich langen Werten voller Sonderzeichen: dann alle Werte knapper.
if (Buffer.byteLength(kopf, "utf8") > HOECHSTLAENGE - 64) kopf = kopfMit(32);
const text =
optionen.text ??
`${eintrag.akteur}: ${eintrag.aktion} ${eintrag.ergebnis}${eintrag.hostName ? ` auf ${eintrag.hostName}` : ""}`;
// Ohne BOM gilt MSG als beliebige Bytes; mit BOM wissen Empfaenger, dass es UTF-8 ist.
const rest = HOECHSTLAENGE - Buffer.byteLength(kopf, "utf8") - 4;
return `${kopf} \uFEFF${textKuerzen(text.replace(/[\r\n]+/g, " "), rest)}`;
}
/** Oktettzaehlung nach RFC 6587 – robuster als Zeilenumbruch als Trenner. */
export function tcpRahmen(nachricht: string): Buffer {
const daten = Buffer.from(nachricht, "utf8");
return Buffer.concat([Buffer.from(`${daten.length} `, "ascii"), daten]);
}
/** Beschreibung fuer die Oberflaeche – Ziel ohne Zugangsdaten (es gibt keine). */
export function syslogZielText(konfig: SyslogKonfig): string {
const host = konfig.host.includes(":") ? `[${konfig.host}]` : konfig.host;
return `${konfig.protokoll}://${host}:${konfig.port}`;
}
export type SyslogSender = {
senden(eintrag: SyslogEintrag): void;
schliessen(): void;
};
/**
* Sender mit Feuer-und-vergessen-Semantik: `senden()` wirft nie und wartet auf nichts.
* Fehler gehen an `fehlerMelden`, und zwar hoechstens einmal je Minute – ein ausgefallener
* Syslog-Server soll das Log nicht fluten und darf nie eine Aktion verhindern.
*/
export function syslogSender(
konfig: SyslogKonfig,
fehlerMelden: (fehler: unknown) => void = (fehler) =>
console.error("[sshkv] Syslog nicht erreichbar:", fehler instanceof Error ? fehler.message : fehler),
jetzt: () => number = Date.now,
): SyslogSender {
let zuletztGemeldet = -Infinity;
const melden = (fehler: unknown) => {
const t = jetzt();
if (t - zuletztGemeldet < 60_000) return;
zuletztGemeldet = t;
try {
fehlerMelden(fehler);
} catch {
// Auch ein kaputter Logger darf hier nichts abbrechen.
}
};
let udp: UdpSocket | null = null;
let tcp: TcpSocket | null = null;
const udpSocket = () => {
if (udp) return udp;
const socket = createSocket(konfig.host.includes(":") ? "udp6" : "udp4");
socket.on("error", (fehler) => {
melden(fehler);
socket.close();
if (udp === socket) udp = null;
});
// Der Socket soll den Prozess (Worker, Tests) nicht am Beenden hindern.
socket.unref();
udp = socket;
return socket;
};
const tcpSocket = () => {
if (tcp && !tcp.destroyed) return tcp;
const socket = connect({ host: konfig.host, port: konfig.port });
socket.setKeepAlive(true);
socket.on("error", (fehler) => {
melden(fehler);
socket.destroy();
});
socket.on("close", () => {
if (tcp === socket) tcp = null;
});
socket.unref();
tcp = socket;
return socket;
};
return {
senden(eintrag) {
try {
const nachricht = syslogNachricht(eintrag, konfig);
if (konfig.protokoll === "tcp") {
// Schreibt vor dem Verbindungsaufbau in den Puffer des Sockets; bei Fehlern gehen
// diese Zeilen verloren – die Datenbank bleibt die Quelle der Wahrheit.
tcpSocket().write(tcpRahmen(nachricht));
} else {
const daten = Buffer.from(nachricht, "utf8");
udpSocket().send(daten, konfig.port, konfig.host, (fehler) => {
if (fehler) melden(fehler);
});
}
} catch (fehler) {
melden(fehler);
}
},
schliessen() {
udp?.close();
udp = null;
tcp?.end();
tcp = null;
},
};
}
+51
View File
@@ -0,0 +1,51 @@
"use server";
import { cursorAus, protokollFilterAus } from "@/lib/protokoll-filter";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { protokollSeite, type ProtokollSeite } from "@/server/queries/protokoll";
import { kettePruefenZwischengespeichert } from "@/server/services/protokoll";
/**
* Protokollansicht (#027): nur lesende Aktionen. Bewusst gibt es hier nichts, was einen
* Eintrag aendert oder loescht – das verbietet ohnehin der Trigger, und
* `tests/protokoll-ansicht.test.ts` haelt fest, dass auch kein Code es versucht.
*/
export type KettenAntwort = {
readonly fehlerhafteId?: string | null;
readonly eintraege?: number;
readonly geprueftAm?: string;
readonly fehler?: string;
};
export async function kettePruefenAktion(): Promise<KettenAntwort> {
try {
const { benutzer } = await rechtErforderlich("lesen");
const ergebnis = await kettePruefenZwischengespeichert({ id: benutzer.id, name: benutzer.name });
return {
fehlerhafteId: ergebnis.fehlerhafteId,
eintraege: ergebnis.eintraege,
geprueftAm: ergebnis.geprueftAm.toISOString(),
};
} catch (fehler) {
if (fehler instanceof RechteFehler) return { fehler: fehler.message };
throw fehler;
}
}
/** „Ältere laden“: dieselben GET-Filter wie die Seite, Cursor ist die kleinste gezeigte ID. */
export async function aeltereLadenAktion(
query: string,
vor: string,
): Promise<ProtokollSeite | { fehler: string }> {
try {
await rechtErforderlich("lesen");
} catch (fehler) {
if (fehler instanceof RechteFehler) return { fehler: fehler.message };
throw fehler;
}
const cursor = cursorAus(String(vor));
if (cursor === undefined) return { zeilen: [], weiterVor: null };
const filter = protokollFilterAus(Object.fromEntries(new URLSearchParams(String(query).slice(0, 2000))));
return protokollSeite(filter, cursor);
}
+156
View File
@@ -0,0 +1,156 @@
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { aktionenDerGruppe } from "@/lib/protokoll-aktionen";
import { zeitraum, type ProtokollFilter } from "@/lib/protokoll-filter";
/**
* Lesemodell des Protokolls (#027). Jede Rolle darf das ganze Protokoll lesen (PLAN §4);
* die Rechtepruefung machen Seite, Action und Route selbst.
*
* Geblaettert wird ueber die ID („älter als“), nicht ueber OFFSET: das Protokoll waechst
* waehrend man liest, und OFFSET wuerde dann Zeilen doppelt zeigen oder ueberspringen –
* ausserdem muesste die Datenbank bei OFFSET 10 000 erst 10 000 Zeilen lesen und verwerfen.
*/
export const SEITENGROESSE = 50;
export type ProtokollZeile = {
readonly id: string;
readonly zeitpunkt: string;
readonly akteurName: string;
readonly aktion: string;
readonly ergebnis: "ERFOLG" | "FEHLER" | "VERWEIGERT";
readonly hostName: string | null;
readonly zielbenutzer: string | null;
readonly fingerprint: string | null;
readonly diff: string | null;
/** Bereits beim Schreiben geschwaerzt (#005); oberste Ebene als Liste, Werte formatiert. */
readonly details: { readonly name: string; readonly wert: string }[] | null;
readonly vorgaengerHash: string;
readonly hash: string;
};
const AUSWAHL = {
id: true,
zeitpunkt: true,
akteurName: true,
aktion: true,
ergebnis: true,
hostName: true,
zielbenutzer: true,
fingerprint: true,
diff: true,
details: true,
vorgaengerHash: true,
hash: true,
} satisfies Prisma.ProtokollSelect;
type Rohzeile = Prisma.ProtokollGetPayload<{ select: typeof AUSWAHL }>;
export function protokollWhere(filter: ProtokollFilter, vor?: bigint): Prisma.ProtokollWhereInput {
const und: Prisma.ProtokollWhereInput[] = [];
const { ab, vor: bis } = zeitraum(filter);
if (ab || bis) und.push({ zeitpunkt: { ...(ab ? { gte: ab } : {}), ...(bis ? { lt: bis } : {}) } });
if (filter.gruppe) und.push({ aktion: { in: aktionenDerGruppe(filter.gruppe) } });
if (filter.akteur) und.push({ akteurName: filter.akteur });
if (filter.host) und.push({ hostName: { contains: filter.host, mode: "insensitive" } });
if (filter.ergebnis) und.push({ ergebnis: filter.ergebnis });
if (filter.q) {
const enthaelt = { contains: filter.q, mode: "insensitive" } as const;
und.push({ OR: [{ fingerprint: enthaelt }, { hostName: enthaelt }, { zielbenutzer: enthaelt }] });
}
if (vor !== undefined) und.push({ id: { lt: vor } });
return und.length ? { AND: und } : {};
}
/** Texte bleiben Texte; Zahlen, Listen und Objekte als eingerueckte JSON. */
function detailsListe(details: Prisma.JsonValue): ProtokollZeile["details"] {
if (details == null) return null;
const alsText = (wert: unknown) => (typeof wert === "string" ? wert : JSON.stringify(wert, null, 2));
if (typeof details !== "object" || Array.isArray(details))
return [{ name: "Wert", wert: alsText(details) }];
const eintraege = Object.entries(details).map(([name, wert]) => ({ name, wert: alsText(wert) }));
return eintraege.length ? eintraege : null;
}
function zeileAus(z: Rohzeile): ProtokollZeile {
return {
id: String(z.id),
zeitpunkt: z.zeitpunkt.toISOString(),
akteurName: z.akteurName,
aktion: z.aktion,
ergebnis: z.ergebnis,
hostName: z.hostName,
zielbenutzer: z.zielbenutzer,
fingerprint: z.fingerprint,
diff: z.diff,
details: detailsListe(z.details),
vorgaengerHash: z.vorgaengerHash,
hash: z.hash,
};
}
export type ProtokollSeite = {
readonly zeilen: ProtokollZeile[];
/** ID fuer „ältere laden“; fehlt, wenn es nichts Aelteres mehr gibt. */
readonly weiterVor: string | null;
};
/** Eine Seite, neueste zuerst; `vor` ist die kleinste ID der vorigen Seite. */
export async function protokollSeite(filter: ProtokollFilter, vor?: bigint): Promise<ProtokollSeite> {
const roh = await prisma.protokoll.findMany({
where: protokollWhere(filter, vor),
orderBy: { id: "desc" },
take: SEITENGROESSE + 1,
select: AUSWAHL,
});
const mehr = roh.length > SEITENGROESSE;
const zeilen = roh.slice(0, SEITENGROESSE).map(zeileAus);
return { zeilen, weiterVor: mehr ? (zeilen.at(-1)?.id ?? null) : null };
}
/** Kopfzeile der Seite: Gesamtzahl und Treffer des Filters. */
export async function protokollZaehlen(
filter: ProtokollFilter,
): Promise<{ gesamt: number; treffer: number }> {
const [gesamt, treffer] = await Promise.all([
prisma.protokoll.count(),
prisma.protokoll.count({ where: protokollWhere(filter) }),
]);
return { gesamt, treffer };
}
/** Namen fuer den Akteur-Filter – haeufigste zuerst, begrenzt, damit die Liste handlich bleibt. */
export async function protokollAkteure(): Promise<string[]> {
const gruppen = await prisma.protokoll.groupBy({
by: ["akteurName"],
_count: { _all: true },
orderBy: { _count: { akteurName: "desc" } },
take: 100,
});
return gruppen.map((g) => g.akteurName).sort((a, b) => a.localeCompare(b, "de"));
}
/**
* Alle Treffer in Stapeln, neueste zuerst – fuer den CSV-Export. Liest je Stapel nur
* `stapel` Zeilen, damit auch ein grosses Protokoll nie ganz im Speicher liegt.
*/
export async function* protokollStapelweise(
filter: ProtokollFilter,
stapel = 500,
): AsyncGenerator<Rohzeile[], void, undefined> {
let vor: bigint | undefined;
for (;;) {
const zeilen = await prisma.protokoll.findMany({
where: protokollWhere(filter, vor),
orderBy: { id: "desc" },
take: stapel,
select: AUSWAHL,
});
if (zeilen.length === 0) return;
yield zeilen;
if (zeilen.length < stapel) return;
vor = zeilen.at(-1)!.id;
}
}
export type { Rohzeile as ProtokollRohzeile };
+6 -1
View File
@@ -1,5 +1,6 @@
import type { AbweichungsArt } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { aktionText } from "@/lib/protokoll-aktionen";
/**
* Lesemodell der Übersicht (#026). Alle Zahlen werden berechnet, nichts ist hinterlegt –
@@ -190,7 +191,11 @@ export async function uebersicht(jetzt = new Date()) {
zeit: a.zeitpunkt,
art: aktionEinordnen(a.aktion, a.ergebnis),
aktion: a.aktion,
beschreibung: [a.aktion, a.zielbenutzer && a.hostName ? `${a.zielbenutzer}@${a.hostName}` : a.hostName]
// Lesbarer Text aus dem Audit-Log (#027) statt des technischen Aktionsnamens.
beschreibung: [
aktionText(a.aktion),
a.zielbenutzer && a.hostName ? `${a.zielbenutzer}@${a.hostName}` : a.hostName,
]
.filter(Boolean)
.join(" · "),
benutzer: a.akteurName,
+109 -2
View File
@@ -2,6 +2,7 @@ import type { Prisma } from "@/generated/prisma/client";
import type { ProtokollErgebnis } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { schwaerzen } from "@/lib/protokoll-schwaerzen";
import { syslogKonfigAus, syslogSender, type SyslogKonfig, type SyslogSender } from "@/lib/syslog";
/**
* Schreibt einen Eintrag ins revisionssichere Protokoll (#005).
@@ -26,7 +27,7 @@ export async function protokollieren(
client: Prisma.TransactionClient = prisma,
): Promise<void> {
const akteur = eintrag.akteur === "System" ? { id: null, name: "System" } : eintrag.akteur;
await client.protokoll.create({
const zeile = await client.protokoll.create({
data: {
akteurId: akteur.id,
akteurName: akteur.name,
@@ -40,13 +41,119 @@ export async function protokollieren(
diff: eintrag.diff ? (schwaerzen(eintrag.diff) as string) : null,
details: eintrag.details ? (schwaerzen(eintrag.details) as Prisma.InputJsonValue) : undefined,
},
select: { id: true, zeitpunkt: true, hash: true },
});
// Erst nach dem erfolgreichen Schreiben, ohne zu warten (#027): Syslog ist die Kopie,
// die Datenbank die Quelle. Innerhalb einer Transaktion kann ein spaeterer Rollback eine
// Syslog-Zeile ohne Gegenstueck hinterlassen – deren Hash fehlt dann in der Kette und faellt
// beim Vergleich auf; umgekehrt (Eintrag ohne Syslog-Zeile) waere schlimmer.
syslogWeitergeben({
id: zeile.id,
zeitpunkt: zeile.zeitpunkt,
akteur: akteur.name,
aktion: eintrag.aktion,
ergebnis: eintrag.ergebnis,
hostName: eintrag.hostName ?? null,
zielbenutzer: eintrag.zielbenutzer ?? null,
fingerprint: eintrag.fingerprint ?? null,
hash: zeile.hash,
});
}
/** Prueft die Hash-Kette in der Datenbank; liefert die erste fehlerhafte ID oder null. */
let syslog: { konfig: SyslogKonfig; sender: SyslogSender } | null | undefined;
/** Konfiguration einmal je Prozess lesen; Web und Worker haben jeder einen Sender. */
export function syslogKonfig(): SyslogKonfig | null {
if (syslog === undefined) {
const konfig = syslogKonfigAus(process.env);
syslog = konfig ? { konfig, sender: syslogSender(konfig) } : null;
}
return syslog?.konfig ?? null;
}
/** Nur fuer Tests: Umgebung neu lesen. */
export function syslogZuruecksetzen(): void {
syslog?.sender.schliessen();
syslog = undefined;
}
function syslogWeitergeben(eintrag: Parameters<SyslogSender["senden"]>[0]): void {
try {
if (!syslogKonfig()) return;
syslog?.sender.senden(eintrag);
} catch {
// senden() wirft nicht; das hier faengt nur eine kaputte Konfiguration ab.
}
}
/**
* Prueft die Hash-Kette in der Datenbank; liefert die erste fehlerhafte ID oder null.
* Liest jede Zeile – fuer die Oberflaeche `ketteGeprueft()` mit Zwischenspeicher nehmen.
*/
export async function protokollKettePruefen(): Promise<bigint | null> {
const [zeile] = await prisma.$queryRaw<
{ ergebnis: bigint | null }[]
>`SELECT protokoll_kette_pruefen() AS ergebnis`;
return zeile?.ergebnis ?? null;
}
export type Kettenpruefung = {
readonly fehlerhafteId: string | null;
readonly eintraege: number;
readonly geprueftAm: Date;
};
/** Wie lange ein Pruefergebnis gilt, bevor „Kette prüfen“ wieder rechnet (#027). */
export const KETTE_ZWISCHENSPEICHER_MS = 5 * 60 * 1000;
let letztePruefung: Kettenpruefung | null = null;
let laufendePruefung: Promise<Kettenpruefung> | null = null;
/**
* Die Pruefung rechnet SHA-256 ueber jede Zeile und waechst mit dem Protokoll; deshalb laeuft
* sie nie beim blossen Seitenaufruf, sondern auf Knopfdruck, und das Ergebnis gilt 5 Minuten
* je Prozess. Gleichzeitige Klicks teilen sich eine laufende Pruefung (#027).
*/
export function ketteZwischengespeichert(jetzt: Date = new Date()): Kettenpruefung | null {
if (!letztePruefung) return null;
return jetzt.getTime() - letztePruefung.geprueftAm.getTime() < KETTE_ZWISCHENSPEICHER_MS
? letztePruefung
: null;
}
export async function kettePruefenZwischengespeichert(
akteur: ProtokollEintrag["akteur"],
): Promise<Kettenpruefung> {
const vorhanden = ketteZwischengespeichert();
if (vorhanden) return vorhanden;
laufendePruefung ??= (async () => {
try {
const eintraege = await prisma.protokoll.count();
const fehlerhaft = await protokollKettePruefen();
const ergebnis = {
fehlerhafteId: fehlerhaft === null ? null : String(fehlerhaft),
eintraege,
geprueftAm: new Date(),
};
if (fehlerhaft !== null) {
// Ein Bruch ist ein Sicherheitsereignis und soll auch beim Syslog ankommen.
await protokollieren({
akteur,
aktion: "protokoll.kette_beschaedigt",
ergebnis: "FEHLER",
details: { fehlerhafteId: String(fehlerhaft) },
});
}
letztePruefung = ergebnis;
return ergebnis;
} finally {
laufendePruefung = null;
}
})();
return laufendePruefung;
}
/** Nur fuer Tests. */
export function ketteZwischenspeicherLeeren(): void {
letztePruefung = null;
}