Merge branch 'main' into phase/029-handbuch
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user