Merge branch 'main' into phase/029-handbuch
This commit is contained in:
@@ -60,6 +60,21 @@ SSHKV_BREAKGLASS_PASSWORD=
|
|||||||
# Zeitplan fuer den Abgleich aller Hosts (cron)
|
# Zeitplan fuer den Abgleich aller Hosts (cron)
|
||||||
SSHKV_SYNC_CRON=30 2 * * *
|
SSHKV_SYNC_CRON=30 2 * * *
|
||||||
|
|
||||||
|
# --- Syslog (optional, #027) ---------------------------------------------------
|
||||||
|
# Jeder Protokolleintrag geht zusaetzlich als RFC-5424-Zeile an einen Syslog-Server – die
|
||||||
|
# Kopie ausserhalb von sshkv. Ohne SSHKV_SYSLOG_HOST ist die Weiterleitung aus. Es gehen nur
|
||||||
|
# Akteur, Aktion, Ergebnis, Host, Zielbenutzer, Fingerprint und Hash hinaus, nie Details/Diff.
|
||||||
|
# Web und Worker protokollieren beide; beide brauchen die Variablen.
|
||||||
|
# SSHKV_SYSLOG_HOST=syslog.intern
|
||||||
|
# Standard 514
|
||||||
|
# SSHKV_SYSLOG_PORT=514
|
||||||
|
# udp (Standard) oder tcp (Oktettzaehlung nach RFC 6587)
|
||||||
|
# SSHKV_SYSLOG_PROTOKOLL=udp
|
||||||
|
# APP-NAME im Syslog-Kopf (Standard sshkv)
|
||||||
|
# SSHKV_SYSLOG_APPNAME=sshkv
|
||||||
|
# Facility (Standard authpriv; auch auth, daemon, user, local0 … local7)
|
||||||
|
# SSHKV_SYSLOG_FACILITY=authpriv
|
||||||
|
|
||||||
# --- Integrationstests (#011) ---------------------------------------------------
|
# --- Integrationstests (#011) ---------------------------------------------------
|
||||||
# SSH-Testziele aus docker-compose.test.yml. Die Compose-Datei liest dieselben Variablen
|
# SSH-Testziele aus docker-compose.test.yml. Die Compose-Datei liest dieselben Variablen
|
||||||
# fuer die Port-Bindung (host:port, nur 127.0.0.1).
|
# fuer die Port-Bindung (host:port, nur 127.0.0.1).
|
||||||
|
|||||||
@@ -64,6 +64,12 @@ services:
|
|||||||
SSHKV_OIDC_GROUP_ADMIN: ${SSHKV_OIDC_GROUP_ADMIN:-sshkv-admins}
|
SSHKV_OIDC_GROUP_ADMIN: ${SSHKV_OIDC_GROUP_ADMIN:-sshkv-admins}
|
||||||
SSHKV_OIDC_GROUP_OPERATOR: ${SSHKV_OIDC_GROUP_OPERATOR:-sshkv-operators}
|
SSHKV_OIDC_GROUP_OPERATOR: ${SSHKV_OIDC_GROUP_OPERATOR:-sshkv-operators}
|
||||||
SSHKV_OIDC_GROUP_READER: ${SSHKV_OIDC_GROUP_READER:-sshkv-readers}
|
SSHKV_OIDC_GROUP_READER: ${SSHKV_OIDC_GROUP_READER:-sshkv-readers}
|
||||||
|
# Syslog-Kopie des Protokolls (#027) – optional, leer heisst aus.
|
||||||
|
SSHKV_SYSLOG_HOST: ${SSHKV_SYSLOG_HOST:-}
|
||||||
|
SSHKV_SYSLOG_PORT: ${SSHKV_SYSLOG_PORT:-514}
|
||||||
|
SSHKV_SYSLOG_PROTOKOLL: ${SSHKV_SYSLOG_PROTOKOLL:-udp}
|
||||||
|
SSHKV_SYSLOG_APPNAME: ${SSHKV_SYSLOG_APPNAME:-sshkv}
|
||||||
|
SSHKV_SYSLOG_FACILITY: ${SSHKV_SYSLOG_FACILITY:-authpriv}
|
||||||
# Absichtlich KEIN Master-Key: eine Luecke im Webprozess allein entschluesselt keine
|
# Absichtlich KEIN Master-Key: eine Luecke im Webprozess allein entschluesselt keine
|
||||||
# Private Keys. Nach aussen braucht er nur den OIDC-Anbieter.
|
# Private Keys. Nach aussen braucht er nur den OIDC-Anbieter.
|
||||||
ports:
|
ports:
|
||||||
@@ -94,6 +100,12 @@ services:
|
|||||||
SSHKV_APP_SECRET: ${SSHKV_APP_SECRET}
|
SSHKV_APP_SECRET: ${SSHKV_APP_SECRET}
|
||||||
SSHKV_MASTER_KEY_FILE: /run/secrets/master_key
|
SSHKV_MASTER_KEY_FILE: /run/secrets/master_key
|
||||||
SSHKV_SYNC_CRON: ${SSHKV_SYNC_CRON:-30 2 * * *}
|
SSHKV_SYNC_CRON: ${SSHKV_SYNC_CRON:-30 2 * * *}
|
||||||
|
# Syslog-Kopie des Protokolls (#027) – optional, leer heisst aus.
|
||||||
|
SSHKV_SYSLOG_HOST: ${SSHKV_SYSLOG_HOST:-}
|
||||||
|
SSHKV_SYSLOG_PORT: ${SSHKV_SYSLOG_PORT:-514}
|
||||||
|
SSHKV_SYSLOG_PROTOKOLL: ${SSHKV_SYSLOG_PROTOKOLL:-udp}
|
||||||
|
SSHKV_SYSLOG_APPNAME: ${SSHKV_SYSLOG_APPNAME:-sshkv}
|
||||||
|
SSHKV_SYSLOG_FACILITY: ${SSHKV_SYSLOG_FACILITY:-authpriv}
|
||||||
secrets:
|
secrets:
|
||||||
- master_key
|
- master_key
|
||||||
# Der Worker braucht die Datenbank und den Weg zu den Hosts.
|
# Der Worker braucht die Datenbank und den Weg zu den Hosts.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 478 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 481 KiB |
@@ -1,19 +1,40 @@
|
|||||||
# #027 Protokollansicht, CSV-Export, Syslog
|
# #027 Protokollansicht, CSV-Export, Syslog
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.5.0
|
**Meilenstein:** v0.5.0
|
||||||
**Labels:** ui, backend
|
**Labels:** ui, backend
|
||||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
**Geschätzt:** 2 h **Tatsächlich:** 0 h 20 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Filterbare Ansicht, CSV, optionale Weiterleitung an Syslog.
|
Filterbare Ansicht, CSV, optionale Weiterleitung an Syslog.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] `/protokoll` statt Platzhalter, jede Rolle liest: Tabelle mit Zeitpunkt (TT.MM.JJJJ HH:MM:SS), Aktion (lesbar, farbig), Akteur, Host, Zielbenutzer, Fingerprint (gekürzt), Ergebnis; unter `md` Karten
|
||||||
|
- [x] Zeile aufklappbar: Diff im Diff-Box-Stil, Details als Liste (Werte als JSON), Hash und Vorgänger-Hash
|
||||||
|
- [x] Lesbare Texte für alle Aktionen in `src/lib/protokoll-aktionen.ts`, gruppiert (Anmeldung, Host-Key, Ausrollen, Schlüssel, Widerruf/Rotation, Zielkonto, Abgleich, Hosts, Personen und Vorlagen, Protokoll)
|
||||||
|
- [x] Filter per GET: Zeitraum (Berliner Kalendertage), Aktionsgruppe, Akteur, Host, Ergebnis, Freitext (Fingerprint/Host/Benutzer)
|
||||||
|
- [x] 50 je Seite, „Ältere laden“ hängt per Server Action an; Cursor über `id`, kein `OFFSET`; `?vor=<id>` als Einstieg
|
||||||
|
- [x] Kopfzeile mit Anzahl und Syslog-Ziel; „Kette prüfen“ (Server Action) mit Ergebnis-Zwischenspeicher für 5 Minuten, Warnung mit Link auf die erste fehlerhafte ID
|
||||||
|
- [x] CSV-Export `/api/protokoll.csv`: eigene Rechteprüfung, gleiche Filter, gestreamt in Stapeln zu 500, Formel-Entschärfung über `src/lib/csv.ts`, Export selbst als `protokoll.exportiert` protokolliert
|
||||||
|
- [x] Kein Lösch-/Bearbeitungsweg; struktureller Test gegen `protokoll.update/delete…` und `UPDATE/DELETE/TRUNCATE "Protokoll"` in `src/`
|
||||||
|
- [x] Syslog nach RFC 5424 (`src/lib/syslog.ts`, UDP oder TCP mit Oktettzählung, ohne neue Abhängigkeit), eingehängt in `protokollieren()` nach dem Schreiben
|
||||||
|
- [x] `.env.example` und `docker-compose.yml` (web und worker) um `SSHKV_SYSLOG_*`
|
||||||
|
- [x] Tests: Query/Cursor/Filter, CSV-Route, Kettenprüfung, Syslog (Format, Escaping, Länge, UDP-/TCP-Empfänger), Aktionstexte, Struktur; E2E `tests/e2e/protokoll.spec.ts`
|
||||||
|
- [x] Screenshots `protokoll-desktop.png` und `protokoll-mobil.png`
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] Jede `aktion: "…"` im Quellcode hat einen lesbaren Text (Test)
|
||||||
|
- [x] Filter und „Ältere laden“ liefern weder Lücken noch Doppelte, auch wenn zwischendurch Einträge dazukommen
|
||||||
|
- [x] CSV ohne Sitzung → 403; Kopfzeile `id;zeitpunkt;akteur;aktion;…;hash`; große Protokolle werden in Stapeln gelesen
|
||||||
|
- [x] Manipulierte Zeile → Warnung mit ID; unversehrte Kette → „Hash-Kette unversehrt“
|
||||||
|
- [x] Ausgefallener Syslog-Server verhindert keine Aktion; Fehler höchstens einmal je Minute im Log
|
||||||
|
- [x] 1440 und 390 px ohne Querscrollen
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#005
|
#005
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Kettenprüfung nur auf Knopfdruck, 5 Minuten zwischengespeichert (je Prozess).** `protokoll_kette_pruefen()` rechnet SHA-256 über jede Zeile und wächst linear mit dem Protokoll; beim bloßen Seitenaufruf wäre das bei 100 000 Einträgen spürbar. Gleichzeitige Klicks teilen sich eine laufende Prüfung. Ein Bruch wird selbst als `protokoll.kette_beschaedigt` (FEHLER) protokolliert, damit er auch beim Syslog ankommt.
|
||||||
|
- **CSV gestreamt** über einen `ReadableStream` mit `pull`: je Stapel 500 Zeilen (Cursor über `id`), der nächste Stapel erst, wenn der Client den vorigen abgenommen hat. Der Exporteintrag wird vor dem ersten Byte geschrieben – scheitert er, gibt es keinen Export.
|
||||||
|
- **IDs sind nicht lückenlos** (Spalten-Default und Trigger ziehen beide aus der Sequenz); Cursor und Tests verlassen sich nur auf die Ordnung.
|
||||||
|
- **Syslog:** RFC 5424, SD-ID `sshkv@32473`, Parameter id/akteur/aktion/ergebnis/host/zielbenutzer/fingerprint/hash (escaped nach §6.3.3, Steuerzeichen ersetzt, je Wert ≤ 120 Byte, Zeile ≤ 2048 Byte), MSGID = Aktion, Severity ERFOLG=notice, VERWEIGERT=warning, FEHLER=err, Facility standardmäßig `authpriv` (`SSHKV_SYSLOG_FACILITY`). Diff und Details gehen nie hinaus. Fire-and-forget nach dem `create`; innerhalb einer Transaktion kann ein späterer Rollback eine Syslog-Zeile ohne Gegenstück hinterlassen – das ist die harmlosere Richtung (ihr Hash fehlt dann in der Kette).
|
||||||
|
- `--ok` nur für erfolgreiche Aktionen, die etwas ändern; Anmeldungen, Abrufe und Export bleiben neutral, Fehler/Verweigert immer `--danger`.
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ const SEITEN: { name: string; pfad: string; oeffentlich?: boolean; folgen?: stri
|
|||||||
pfad: "/vorlagen",
|
pfad: "/vorlagen",
|
||||||
folgen: 'main a[aria-label="Deployment-Zugang bearbeiten"]',
|
folgen: 'main a[aria-label="Deployment-Zugang bearbeiten"]',
|
||||||
},
|
},
|
||||||
|
// Audit-Log (#027): unter md Karten statt Tabelle, die Seite darf nicht seitwaerts scrollen.
|
||||||
|
{ name: "protokoll", pfad: "/protokoll" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ANSICHTEN = [
|
const ANSICHTEN = [
|
||||||
|
|||||||
@@ -1,18 +1,202 @@
|
|||||||
|
import { Download, ScrollText, Search } from "lucide-react";
|
||||||
import type { Metadata } from "next";
|
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 { 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 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).
|
// 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 (
|
return (
|
||||||
<Platzhalterseite
|
<div className="flex min-w-0 flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
titel="Audit-Log"
|
titel="Audit-Log"
|
||||||
untertitel="Lückenloses, verkettetes Protokoll aller Aktionen"
|
untertitel="Lückenloses, verkettetes Protokoll aller Aktionen – nur lesbar, nicht änderbar"
|
||||||
phase={5}
|
aktionen={
|
||||||
issue="#027"
|
<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 type { AbweichungsArt } from "@/generated/prisma/enums";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { aktionText } from "@/lib/protokoll-aktionen";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lesemodell der Übersicht (#026). Alle Zahlen werden berechnet, nichts ist hinterlegt –
|
* 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,
|
zeit: a.zeitpunkt,
|
||||||
art: aktionEinordnen(a.aktion, a.ergebnis),
|
art: aktionEinordnen(a.aktion, a.ergebnis),
|
||||||
aktion: a.aktion,
|
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)
|
.filter(Boolean)
|
||||||
.join(" · "),
|
.join(" · "),
|
||||||
benutzer: a.akteurName,
|
benutzer: a.akteurName,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Prisma } from "@/generated/prisma/client";
|
|||||||
import type { ProtokollErgebnis } from "@/generated/prisma/enums";
|
import type { ProtokollErgebnis } from "@/generated/prisma/enums";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { schwaerzen } from "@/lib/protokoll-schwaerzen";
|
import { schwaerzen } from "@/lib/protokoll-schwaerzen";
|
||||||
|
import { syslogKonfigAus, syslogSender, type SyslogKonfig, type SyslogSender } from "@/lib/syslog";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schreibt einen Eintrag ins revisionssichere Protokoll (#005).
|
* Schreibt einen Eintrag ins revisionssichere Protokoll (#005).
|
||||||
@@ -26,7 +27,7 @@ export async function protokollieren(
|
|||||||
client: Prisma.TransactionClient = prisma,
|
client: Prisma.TransactionClient = prisma,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const akteur = eintrag.akteur === "System" ? { id: null, name: "System" } : eintrag.akteur;
|
const akteur = eintrag.akteur === "System" ? { id: null, name: "System" } : eintrag.akteur;
|
||||||
await client.protokoll.create({
|
const zeile = await client.protokoll.create({
|
||||||
data: {
|
data: {
|
||||||
akteurId: akteur.id,
|
akteurId: akteur.id,
|
||||||
akteurName: akteur.name,
|
akteurName: akteur.name,
|
||||||
@@ -40,13 +41,119 @@ export async function protokollieren(
|
|||||||
diff: eintrag.diff ? (schwaerzen(eintrag.diff) as string) : null,
|
diff: eintrag.diff ? (schwaerzen(eintrag.diff) as string) : null,
|
||||||
details: eintrag.details ? (schwaerzen(eintrag.details) as Prisma.InputJsonValue) : undefined,
|
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> {
|
export async function protokollKettePruefen(): Promise<bigint | null> {
|
||||||
const [zeile] = await prisma.$queryRaw<
|
const [zeile] = await prisma.$queryRaw<
|
||||||
{ ergebnis: bigint | null }[]
|
{ ergebnis: bigint | null }[]
|
||||||
>`SELECT protokoll_kette_pruefen() AS ergebnis`;
|
>`SELECT protokoll_kette_pruefen() AS ergebnis`;
|
||||||
return zeile?.ergebnis ?? null;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { anmelden } from "./hilfen";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit-Log (#027) gegen den gebauten Stand: Liste, Filter, Aufklappen, Kettenpruefung und
|
||||||
|
* CSV-Export. Die Anmeldung selbst erzeugt den ersten Eintrag – ohne SSH-Testziele lauffaehig.
|
||||||
|
*/
|
||||||
|
test("Audit-Log zeigt, filtert, prüft und exportiert", async ({ page }) => {
|
||||||
|
await anmelden(page);
|
||||||
|
await page.goto("/protokoll");
|
||||||
|
await expect(page.getByRole("heading", { name: "Audit-Log" })).toBeVisible();
|
||||||
|
|
||||||
|
const tabelle = page.getByRole("table", { name: "Protokolleinträge" });
|
||||||
|
await expect(tabelle.getByRole("row").nth(1)).toBeVisible();
|
||||||
|
|
||||||
|
// Filter nach Aktionsgruppe: nur noch Anmeldungen.
|
||||||
|
await page.getByLabel("Aktion").selectOption({ label: "Anmeldung" });
|
||||||
|
await page.getByRole("button", { name: "Filtern" }).click();
|
||||||
|
await expect(page).toHaveURL(/gruppe=anmeldung/);
|
||||||
|
const zeilen = tabelle.locator("tbody > tr");
|
||||||
|
await expect(zeilen.first()).toBeVisible();
|
||||||
|
const aktionen = await zeilen.locator("td:nth-child(2)").allInnerTexts();
|
||||||
|
expect(aktionen.length).toBeGreaterThan(0);
|
||||||
|
for (const aktion of aktionen) expect(aktion).toMatch(/Anmeld|TOTP/);
|
||||||
|
|
||||||
|
// Zeile aufklappen: Hash und Vorgaenger-Hash erscheinen.
|
||||||
|
await tabelle
|
||||||
|
.getByRole("button", { name: /Einzelheiten zu Eintrag/ })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await expect(tabelle.locator('[data-feld="hash"]').first()).toHaveText(/^[0-9a-f]{64}$/);
|
||||||
|
|
||||||
|
// Kettenpruefung auf Knopfdruck.
|
||||||
|
await page.getByRole("button", { name: "Kette prüfen" }).click();
|
||||||
|
await expect(page.getByText("Hash-Kette unversehrt")).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
// CSV mit denselben Filtern; der Export landet selbst im Protokoll.
|
||||||
|
const [download] = await Promise.all([
|
||||||
|
page.waitForEvent("download"),
|
||||||
|
page.getByRole("link", { name: "CSV exportieren" }).click(),
|
||||||
|
]);
|
||||||
|
expect(download.suggestedFilename()).toMatch(/^sshkv-protokoll-\d{4}-\d{2}-\d{2}\.csv$/);
|
||||||
|
const text = (await readFile((await download.path())!, "utf8")).replace(/^/, "");
|
||||||
|
expect(text.split("\r\n")[0]).toBe(
|
||||||
|
'"id";"zeitpunkt";"akteur";"aktion";"ergebnis";"host";"zielbenutzer";"fingerprint";"diff";"details";"vorgaengerHash";"hash"',
|
||||||
|
);
|
||||||
|
expect(text).toContain("anmeldung.");
|
||||||
|
|
||||||
|
await page.goto("/protokoll?gruppe=protokoll");
|
||||||
|
await expect(tabelle.getByText("Protokoll exportiert (CSV)").first()).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { AKTIONEN, AKTIONSGRUPPEN, aktionenDerGruppe, aktionText, aktionTon } from "@/lib/protokoll-aktionen";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jede Protokoll-Aktion im Quellcode hat einen lesbaren Text (#027). Gesucht wird in allen
|
||||||
|
* Dateien, die `protokollieren(` rufen, nach `aktion: …`/`aktion = …` (auch in Ternaries), und
|
||||||
|
* ueberall nach Literalen, die mit einem bekannten Praefix beginnen (`"schluessel.uebernommen"`
|
||||||
|
* wird z. B. als Argument durchgereicht).
|
||||||
|
*/
|
||||||
|
function dateien(verzeichnis: string): string[] {
|
||||||
|
return readdirSync(verzeichnis).flatMap((name) => {
|
||||||
|
const pfad = path.join(verzeichnis, name);
|
||||||
|
if (statSync(pfad).isDirectory()) return name === "generated" ? [] : dateien(pfad);
|
||||||
|
return /\.tsx?$/.test(name) ? [pfad] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUELLEN = dateien("src").map((datei) => ({ datei, inhalt: readFileSync(datei, "utf8") }));
|
||||||
|
const LITERAL = /"([^"\n]+)"/g;
|
||||||
|
|
||||||
|
function aktionenImCode(): Map<string, string> {
|
||||||
|
const gefunden = new Map<string, string>();
|
||||||
|
const praefixe = new Set(Object.keys(AKTIONEN).map((a) => a.split(".")[0]!));
|
||||||
|
for (const { datei, inhalt } of QUELLEN) {
|
||||||
|
if (inhalt.includes("protokollieren(")) {
|
||||||
|
for (const treffer of inhalt.matchAll(/\baktion\s*[:=]\s*([^,\n]+)/g)) {
|
||||||
|
// Bei `a === "x" ? "eins" : "zwei"` zaehlen nur die Zweige, nicht die Bedingung.
|
||||||
|
const ausdruck = treffer[1]!.includes("?")
|
||||||
|
? treffer[1]!.slice(treffer[1]!.indexOf("?"))
|
||||||
|
: treffer[1]!;
|
||||||
|
for (const [, literal] of ausdruck.matchAll(LITERAL)) gefunden.set(literal!, datei);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [, literal] of inhalt.matchAll(/"([a-z_]+(?:\.[a-z_]+)+)"/g)) {
|
||||||
|
if (praefixe.has(literal!.split(".")[0]!) && !literal!.endsWith(".ts")) gefunden.set(literal!, datei);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return gefunden;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Protokoll-Aktionen", () => {
|
||||||
|
const imCode = aktionenImCode();
|
||||||
|
|
||||||
|
it("findet die Aktionen im Quellcode", () => {
|
||||||
|
expect(imCode.size).toBeGreaterThan(40);
|
||||||
|
expect(imCode.has("authorized_keys.geschrieben")).toBe(true);
|
||||||
|
expect(imCode.has("hostkey.ersetzt")).toBe(true);
|
||||||
|
expect(imCode.has("privatekey.angezeigt")).toBe(true);
|
||||||
|
expect(imCode.has("schluessel.uebernommen")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([...imCode])("%s hat einen lesbaren Text (%s)", (aktion) => {
|
||||||
|
expect(AKTIONEN[aktion], `${aktion} fehlt in src/lib/protokoll-aktionen.ts`).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("jede Gruppe hat Aktionen", () => {
|
||||||
|
for (const gruppe of Object.keys(AKTIONSGRUPPEN) as (keyof typeof AKTIONSGRUPPEN)[]) {
|
||||||
|
expect(aktionenDerGruppe(gruppe).length, gruppe).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("faellt fuer unbekannte Aktionen auf den technischen Namen zurueck", () => {
|
||||||
|
expect(aktionText("hostkey.ersetzt")).toBe("Host-Key ersetzt");
|
||||||
|
expect(aktionText("gibt.es.nicht")).toBe("gibt.es.nicht");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("faerbt nach Ergebnis und Art", () => {
|
||||||
|
expect(aktionTon("authorized_keys.geschrieben", "ERFOLG")).toBe("ok");
|
||||||
|
expect(aktionTon("anmeldung.oidc", "ERFOLG")).toBe("neutral");
|
||||||
|
expect(aktionTon("anmeldung.verweigert", "VERWEIGERT")).toBe("gefahr");
|
||||||
|
expect(aktionTon("verbindungstest", "FEHLER")).toBe("gefahr");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
import { createSocket } from "node:dgram";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { LEERER_FILTER, protokollFilterAus, type ProtokollFilter } from "@/lib/protokoll-filter";
|
||||||
|
import {
|
||||||
|
protokollAkteure,
|
||||||
|
protokollSeite,
|
||||||
|
protokollStapelweise,
|
||||||
|
protokollZaehlen,
|
||||||
|
SEITENGROESSE,
|
||||||
|
} from "@/server/queries/protokoll";
|
||||||
|
import {
|
||||||
|
kettePruefenZwischengespeichert,
|
||||||
|
ketteZwischengespeichert,
|
||||||
|
ketteZwischenspeicherLeeren,
|
||||||
|
protokollieren,
|
||||||
|
syslogKonfig,
|
||||||
|
syslogZuruecksetzen,
|
||||||
|
} from "@/server/services/protokoll";
|
||||||
|
import { datenbankLeeren } from "./hilfen/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Protokollansicht (#027) gegen die Testdatenbank: Filter, Cursor, CSV-Route, Kettenpruefung
|
||||||
|
* und Syslog-Weiterleitung. Die Sitzung der Route wird nachgebildet – geprueft wird, dass der
|
||||||
|
* Handler das Recht selbst verlangt.
|
||||||
|
*/
|
||||||
|
const recht = vi.hoisted(() => ({ erlaubt: true }));
|
||||||
|
vi.mock("@/server/auth/sitzungspflicht", async (original) => {
|
||||||
|
const echt = await original<typeof import("@/server/auth/sitzungspflicht")>();
|
||||||
|
return {
|
||||||
|
...echt,
|
||||||
|
rechtErforderlich: vi.fn(async () => {
|
||||||
|
if (!recht.erlaubt) throw new echt.RechteFehler("Nicht angemeldet.");
|
||||||
|
return { benutzer: { id: "b-leser", name: "Lea Leser", rolle: "LESER" } };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
type Roh = {
|
||||||
|
aktion: string;
|
||||||
|
akteurName?: string;
|
||||||
|
ergebnis?: "ERFOLG" | "FEHLER" | "VERWEIGERT";
|
||||||
|
hostName?: string | null;
|
||||||
|
zielbenutzer?: string | null;
|
||||||
|
fingerprint?: string | null;
|
||||||
|
zeitpunkt?: Date;
|
||||||
|
diff?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function eintragen(daten: Roh) {
|
||||||
|
return prisma.protokoll.create({
|
||||||
|
data: {
|
||||||
|
akteurName: daten.akteurName ?? "Florian",
|
||||||
|
aktion: daten.aktion,
|
||||||
|
ergebnis: daten.ergebnis ?? "ERFOLG",
|
||||||
|
hostName: daten.hostName ?? null,
|
||||||
|
zielbenutzer: daten.zielbenutzer ?? null,
|
||||||
|
fingerprint: daten.fingerprint ?? null,
|
||||||
|
diff: daten.diff ?? null,
|
||||||
|
...(daten.zeitpunkt ? { zeitpunkt: daten.zeitpunkt } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const filter = (teil: Partial<ProtokollFilter>): ProtokollFilter => ({ ...LEERER_FILTER, ...teil });
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
recht.erlaubt = true;
|
||||||
|
ketteZwischenspeicherLeeren();
|
||||||
|
await datenbankLeeren();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("protokollSeite", () => {
|
||||||
|
it("blaettert ueber die ID, neueste zuerst, ohne Luecken und Doppelte", async () => {
|
||||||
|
// IDs sind nicht lueckenlos (der Trigger zieht die Sequenz selbst) – nur die Ordnung zaehlt.
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (let i = 0; i < SEITENGROESSE + 7; i += 1)
|
||||||
|
ids.unshift(String((await eintragen({ aktion: "host.angelegt" })).id));
|
||||||
|
const erste = await protokollSeite(LEERER_FILTER);
|
||||||
|
expect(erste.zeilen.map((z) => z.id)).toEqual(ids.slice(0, SEITENGROESSE));
|
||||||
|
expect(erste.weiterVor).toBe(ids[SEITENGROESSE - 1]);
|
||||||
|
|
||||||
|
// Ein neuer Eintrag zwischen zwei Seiten verschiebt den Cursor nicht (anders als OFFSET).
|
||||||
|
await eintragen({ aktion: "host.geaendert" });
|
||||||
|
const zweite = await protokollSeite(LEERER_FILTER, BigInt(erste.weiterVor!));
|
||||||
|
expect(zweite.zeilen.map((z) => z.id)).toEqual(ids.slice(SEITENGROESSE));
|
||||||
|
expect(zweite.weiterVor).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("liefert Hash, Vorgaenger-Hash und Details als Liste", async () => {
|
||||||
|
await protokollieren({
|
||||||
|
akteur: "System",
|
||||||
|
aktion: "abgleich",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
details: { befunde: 2, benutzer: ["root"], hinweis: "ok" },
|
||||||
|
});
|
||||||
|
const [zeile] = (await protokollSeite(LEERER_FILTER)).zeilen;
|
||||||
|
expect(zeile!.hash).toMatch(/^[0-9a-f]{64}$/);
|
||||||
|
expect(zeile!.vorgaengerHash).toBe("");
|
||||||
|
// jsonb ordnet die Schluessel selbst; die Reihenfolge ist daher nicht Teil der Zusage.
|
||||||
|
expect(zeile!.details).toHaveLength(3);
|
||||||
|
expect(zeile!.details).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ name: "befunde", wert: "2" },
|
||||||
|
{ name: "benutzer", wert: '[\n "root"\n]' },
|
||||||
|
{ name: "hinweis", wert: "ok" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filtert nach Aktionsgruppe, Ergebnis, Akteur, Host und Freitext", async () => {
|
||||||
|
await eintragen({ aktion: "anmeldung.oidc", akteurName: "Anna" });
|
||||||
|
await eintragen({ aktion: "anmeldung.verweigert", akteurName: "Mallory", ergebnis: "VERWEIGERT" });
|
||||||
|
await eintragen({
|
||||||
|
aktion: "authorized_keys.geschrieben",
|
||||||
|
hostName: "srv-pve01",
|
||||||
|
zielbenutzer: "backup",
|
||||||
|
fingerprint: "SHA256:abcdef",
|
||||||
|
});
|
||||||
|
await eintragen({ aktion: "hostkey.ersetzt", hostName: "srv-db02" });
|
||||||
|
|
||||||
|
const aktionen = async (f: Partial<ProtokollFilter>) =>
|
||||||
|
(await protokollSeite(filter(f))).zeilen.map((z) => z.aktion);
|
||||||
|
|
||||||
|
expect(await aktionen({ gruppe: "anmeldung" })).toEqual(["anmeldung.verweigert", "anmeldung.oidc"]);
|
||||||
|
expect(await aktionen({ gruppe: "hostkey" })).toEqual(["hostkey.ersetzt"]);
|
||||||
|
expect(await aktionen({ ergebnis: "VERWEIGERT" })).toEqual(["anmeldung.verweigert"]);
|
||||||
|
expect(await aktionen({ akteur: "Anna" })).toEqual(["anmeldung.oidc"]);
|
||||||
|
expect(await aktionen({ host: "PVE" })).toEqual(["authorized_keys.geschrieben"]);
|
||||||
|
expect(await aktionen({ q: "abcdef" })).toEqual(["authorized_keys.geschrieben"]);
|
||||||
|
expect(await aktionen({ q: "BACKUP" })).toEqual(["authorized_keys.geschrieben"]);
|
||||||
|
expect(await aktionen({ q: "db02" })).toEqual(["hostkey.ersetzt"]);
|
||||||
|
expect(await aktionen({ gruppe: "ausrollen", host: "db02" })).toEqual([]);
|
||||||
|
|
||||||
|
expect(await protokollZaehlen(filter({ gruppe: "anmeldung" }))).toEqual({ gesamt: 4, treffer: 2 });
|
||||||
|
expect(await protokollAkteure()).toEqual(["Anna", "Florian", "Mallory"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filtert den Zeitraum nach Berliner Kalendertagen", async () => {
|
||||||
|
// 11.10. 23:30 Berlin = 21:30 UTC; 12.10. 00:30 Berlin = 22:30 UTC am 11.10.
|
||||||
|
await eintragen({ aktion: "host.angelegt", zeitpunkt: new Date("2026-10-11T21:30:00Z") });
|
||||||
|
await eintragen({ aktion: "host.geaendert", zeitpunkt: new Date("2026-10-11T22:30:00Z") });
|
||||||
|
await eintragen({ aktion: "host.archiviert", zeitpunkt: new Date("2026-10-12T21:59:00Z") });
|
||||||
|
await eintragen({ aktion: "kunde.angelegt", zeitpunkt: new Date("2026-10-12T22:01:00Z") });
|
||||||
|
|
||||||
|
const aktionen = async (f: Partial<ProtokollFilter>) =>
|
||||||
|
(await protokollSeite(filter(f))).zeilen.map((z) => z.aktion);
|
||||||
|
expect(await aktionen({ von: "2026-10-12", bis: "2026-10-12" })).toEqual([
|
||||||
|
"host.archiviert",
|
||||||
|
"host.geaendert",
|
||||||
|
]);
|
||||||
|
expect(await aktionen({ bis: "2026-10-11" })).toEqual(["host.angelegt"]);
|
||||||
|
expect(await aktionen({ von: "2026-10-13" })).toEqual(["kunde.angelegt"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignoriert unbekannte Filterwerte", () => {
|
||||||
|
expect(
|
||||||
|
protokollFilterAus({
|
||||||
|
gruppe: "drop table",
|
||||||
|
ergebnis: "VIELLEICHT",
|
||||||
|
von: "12.10.2026",
|
||||||
|
bis: "2026-13-45",
|
||||||
|
}),
|
||||||
|
).toEqual(LEERER_FILTER);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("protokollStapelweise", () => {
|
||||||
|
it("liest alle Treffer in Stapeln", async () => {
|
||||||
|
const erwartet: bigint[] = [];
|
||||||
|
for (let i = 0; i < 12; i += 1) {
|
||||||
|
const zeile = await eintragen({ aktion: i % 2 ? "host.angelegt" : "anmeldung.oidc" });
|
||||||
|
if (i % 2) erwartet.unshift(zeile.id);
|
||||||
|
}
|
||||||
|
const groessen: number[] = [];
|
||||||
|
const ids: bigint[] = [];
|
||||||
|
for await (const stapel of protokollStapelweise(filter({ gruppe: "hosts" }), 4)) {
|
||||||
|
groessen.push(stapel.length);
|
||||||
|
ids.push(...stapel.map((z) => z.id));
|
||||||
|
}
|
||||||
|
expect(groessen).toEqual([4, 2]);
|
||||||
|
expect(ids).toEqual(erwartet);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/protokoll.csv", () => {
|
||||||
|
async function abrufen(query = "") {
|
||||||
|
const { GET } = await import("@/app/api/protokoll.csv/route");
|
||||||
|
return GET(new Request(`http://localhost/api/protokoll.csv${query}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
it("verweigert ohne Recht und protokolliert dann nichts", async () => {
|
||||||
|
recht.erlaubt = false;
|
||||||
|
const antwort = await abrufen();
|
||||||
|
expect(antwort.status).toBe(403);
|
||||||
|
expect(await prisma.protokoll.count()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("liefert Kopfzeile, Zeilen und entschaerft Formeln", async () => {
|
||||||
|
await eintragen({
|
||||||
|
aktion: "authorized_keys.geschrieben",
|
||||||
|
akteurName: "=HYPERLINK(1)",
|
||||||
|
hostName: "srv-pve01",
|
||||||
|
zielbenutzer: "root",
|
||||||
|
fingerprint: "SHA256:abc",
|
||||||
|
diff: '+ ssh-ed25519 AAAA "x"\n- ssh-rsa BBBB',
|
||||||
|
});
|
||||||
|
const antwort = await abrufen();
|
||||||
|
expect(antwort.status).toBe(200);
|
||||||
|
expect(antwort.headers.get("content-type")).toContain("text/csv");
|
||||||
|
expect(antwort.headers.get("content-disposition")).toMatch(/sshkv-protokoll-\d{4}-\d{2}-\d{2}\.csv/);
|
||||||
|
const text = (await antwort.text()).replace(/^/, "");
|
||||||
|
const [kopf] = text.split("\r\n");
|
||||||
|
expect(kopf).toBe(
|
||||||
|
'"id";"zeitpunkt";"akteur";"aktion";"ergebnis";"host";"zielbenutzer";"fingerprint";"diff";"details";"vorgaengerHash";"hash"',
|
||||||
|
);
|
||||||
|
expect(text).toContain(`"'=HYPERLINK(1)";"authorized_keys.geschrieben";"ERFOLG";"srv-pve01";"root"`);
|
||||||
|
// Mehrzeiliger Diff bleibt eine Zelle; das fuehrende „+“ wird wie eine Formel entschaerft.
|
||||||
|
expect(text).toContain(`"'+ ssh-ed25519 AAAA ""x""\n- ssh-rsa BBBB"`);
|
||||||
|
expect(text).toMatch(/\r\n"\d+";"\d{4}-\d{2}-\d{2}T[\d:.]+Z";"'=HYPERLINK/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("protokolliert den Export mit den Filtern", async () => {
|
||||||
|
await (await abrufen("?gruppe=hostkey&q=srv")).text();
|
||||||
|
const [eintrag] = await prisma.protokoll.findMany({ where: { aktion: "protokoll.exportiert" } });
|
||||||
|
expect(eintrag).toMatchObject({ akteurName: "Lea Leser", akteurId: "b-leser", ergebnis: "ERFOLG" });
|
||||||
|
expect(eintrag!.details).toEqual({ filter: { gruppe: "hostkey", q: "srv" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wendet die Filter der Seite an", async () => {
|
||||||
|
await eintragen({ aktion: "hostkey.ersetzt", hostName: "srv-a" });
|
||||||
|
await eintragen({ aktion: "anmeldung.oidc" });
|
||||||
|
const text = await (await abrufen("?gruppe=hostkey")).text();
|
||||||
|
expect(text).toContain("hostkey.ersetzt");
|
||||||
|
expect(text).not.toContain("anmeldung.oidc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("streamt grosse Protokolle ueber mehrere Stapel", async () => {
|
||||||
|
await prisma.protokoll.createMany({
|
||||||
|
data: Array.from({ length: 1203 }, (_, i) => ({
|
||||||
|
akteurName: "System",
|
||||||
|
aktion: "abgleich",
|
||||||
|
ergebnis: "ERFOLG" as const,
|
||||||
|
hostName: `h${i}`,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
const text = await (await abrufen("?gruppe=abgleich")).text();
|
||||||
|
expect(text.trimEnd().split("\r\n")).toHaveLength(1 + 1203);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Kettenpruefung", () => {
|
||||||
|
it("prueft auf Anfrage und haelt das Ergebnis 5 Minuten", async () => {
|
||||||
|
await eintragen({ aktion: "host.angelegt" });
|
||||||
|
await eintragen({ aktion: "host.geaendert" });
|
||||||
|
expect(ketteZwischengespeichert()).toBeNull();
|
||||||
|
const ergebnis = await kettePruefenZwischengespeichert("System");
|
||||||
|
expect(ergebnis).toMatchObject({ fehlerhafteId: null, eintraege: 2 });
|
||||||
|
expect(ketteZwischengespeichert()).toBe(ergebnis);
|
||||||
|
expect(ketteZwischengespeichert(new Date(Date.now() + 6 * 60 * 1000))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("meldet die erste fehlerhafte ID und protokolliert den Fund", async () => {
|
||||||
|
await eintragen({ aktion: "host.angelegt" });
|
||||||
|
const zweiter = await eintragen({ aktion: "host.geaendert" });
|
||||||
|
await eintragen({ aktion: "host.archiviert" });
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.$executeRawUnsafe(`ALTER TABLE "Protokoll" DISABLE TRIGGER "protokoll_kein_update"`),
|
||||||
|
prisma.$executeRawUnsafe(`UPDATE "Protokoll" SET "aktion" = 'gefaelscht' WHERE "id" = ${zweiter.id}`),
|
||||||
|
prisma.$executeRawUnsafe(`ALTER TABLE "Protokoll" ENABLE TRIGGER "protokoll_kein_update"`),
|
||||||
|
]);
|
||||||
|
const ergebnis = await kettePruefenZwischengespeichert({ id: "b1", name: "Florian" });
|
||||||
|
expect(ergebnis.fehlerhafteId).toBe(String(zweiter.id));
|
||||||
|
expect(await prisma.protokoll.count({ where: { aktion: "protokoll.kette_beschaedigt" } })).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Syslog-Weiterleitung in protokollieren()", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env["SSHKV_SYSLOG_HOST"];
|
||||||
|
delete process.env["SSHKV_SYSLOG_PORT"];
|
||||||
|
syslogZuruecksetzen();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ist ohne Umgebung aus", () => {
|
||||||
|
syslogZuruecksetzen();
|
||||||
|
expect(syslogKonfig()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sendet nach dem Schreiben – ohne Details und Diff", async () => {
|
||||||
|
const empfaenger = createSocket("udp4");
|
||||||
|
const eingang = new Promise<string>((fertig) =>
|
||||||
|
empfaenger.once("message", (d) => fertig(d.toString("utf8"))),
|
||||||
|
);
|
||||||
|
await new Promise<void>((fertig) => empfaenger.bind(0, "127.0.0.1", fertig));
|
||||||
|
process.env["SSHKV_SYSLOG_HOST"] = "127.0.0.1";
|
||||||
|
process.env["SSHKV_SYSLOG_PORT"] = String((empfaenger.address() as AddressInfo).port);
|
||||||
|
syslogZuruecksetzen();
|
||||||
|
try {
|
||||||
|
await protokollieren({
|
||||||
|
akteur: { id: "b1", name: "Florian" },
|
||||||
|
aktion: "privatekey.angezeigt",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
fingerprint: "SHA256:xyz",
|
||||||
|
diff: "+ geheimer-diff",
|
||||||
|
details: { pfad: "/geheim/pfad", passwort: "x" },
|
||||||
|
});
|
||||||
|
const zeile = await eingang;
|
||||||
|
const [eintrag] = await prisma.protokoll.findMany();
|
||||||
|
expect(zeile).toMatch(/^<85>1 /);
|
||||||
|
expect(zeile).toContain(`hash="${eintrag!.hash}"`);
|
||||||
|
expect(zeile).toContain(`id="${eintrag!.id}"`);
|
||||||
|
expect(zeile).toContain('fingerprint="SHA256:xyz"');
|
||||||
|
expect(zeile).not.toContain("geheimer-diff");
|
||||||
|
expect(zeile).not.toContain("/geheim/pfad");
|
||||||
|
} finally {
|
||||||
|
empfaenger.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schreibt auch, wenn der Syslog-Server nicht erreichbar ist", async () => {
|
||||||
|
process.env["SSHKV_SYSLOG_HOST"] = "nicht-aufloesbar.invalid";
|
||||||
|
syslogZuruecksetzen();
|
||||||
|
const fehler = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
await protokollieren({ akteur: "System", aktion: "abgleich", ergebnis: "ERFOLG" });
|
||||||
|
expect(await prisma.protokoll.count()).toBe(1);
|
||||||
|
fehler.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Protokoll ist ueber die Anwendung nicht aenderbar", () => {
|
||||||
|
function dateien(verzeichnis: string): string[] {
|
||||||
|
return readdirSync(verzeichnis).flatMap((name) => {
|
||||||
|
const pfad = path.join(verzeichnis, name);
|
||||||
|
if (statSync(pfad).isDirectory()) return name === "generated" ? [] : dateien(pfad);
|
||||||
|
return /\.(ts|tsx|sql)$/.test(name) ? [pfad] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it.each(dateien("src"))("%s aendert und loescht keine Protokolleintraege", (datei) => {
|
||||||
|
const inhalt = readFileSync(datei, "utf8");
|
||||||
|
expect(inhalt).not.toMatch(/\.protokoll\s*\.\s*(update|updateMany|upsert|delete|deleteMany)\b/);
|
||||||
|
expect(inhalt).not.toMatch(/(UPDATE|DELETE\s+FROM|TRUNCATE)\s+"?Protokoll"?/i);
|
||||||
|
expect(inhalt).not.toMatch(/DISABLE\s+TRIGGER/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { createSocket, type Socket } from "node:dgram";
|
||||||
|
import { createServer, type Server } from "node:net";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
HOECHSTLAENGE,
|
||||||
|
sdWertEscapen,
|
||||||
|
syslogKonfigAus,
|
||||||
|
syslogNachricht,
|
||||||
|
syslogSender,
|
||||||
|
syslogZielText,
|
||||||
|
tcpRahmen,
|
||||||
|
type SyslogEintrag,
|
||||||
|
} from "@/lib/syslog";
|
||||||
|
|
||||||
|
/** Syslog-Weiterleitung (#027): Format nach RFC 5424, Escaping, Laenge, Versand. */
|
||||||
|
|
||||||
|
const EINTRAG: SyslogEintrag = {
|
||||||
|
id: 42n,
|
||||||
|
zeitpunkt: new Date("2026-10-12T14:03:07.123Z"),
|
||||||
|
akteur: "Florian",
|
||||||
|
aktion: "authorized_keys.geschrieben",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
hostName: "srv-pve01",
|
||||||
|
zielbenutzer: "root",
|
||||||
|
fingerprint: "SHA256:abc",
|
||||||
|
hash: "f00",
|
||||||
|
};
|
||||||
|
const KONFIG = { appName: "sshkv", facility: 10 };
|
||||||
|
const FEST = { rechner: "vault", prozess: 7 };
|
||||||
|
|
||||||
|
describe("syslogKonfigAus", () => {
|
||||||
|
it("ist ohne Host aus", () => {
|
||||||
|
expect(syslogKonfigAus({})).toBeNull();
|
||||||
|
expect(syslogKonfigAus({ SSHKV_SYSLOG_HOST: " " })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setzt Standardwerte", () => {
|
||||||
|
expect(syslogKonfigAus({ SSHKV_SYSLOG_HOST: "log.intern" })).toEqual({
|
||||||
|
host: "log.intern",
|
||||||
|
port: 514,
|
||||||
|
protokoll: "udp",
|
||||||
|
appName: "sshkv",
|
||||||
|
facility: 10,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("liest Port, Protokoll, App-Name und Facility", () => {
|
||||||
|
const konfig = syslogKonfigAus({
|
||||||
|
SSHKV_SYSLOG_HOST: "10.0.0.5",
|
||||||
|
SSHKV_SYSLOG_PORT: "6514",
|
||||||
|
SSHKV_SYSLOG_PROTOKOLL: "TCP",
|
||||||
|
SSHKV_SYSLOG_APPNAME: "vault prod",
|
||||||
|
SSHKV_SYSLOG_FACILITY: "local3",
|
||||||
|
});
|
||||||
|
expect(konfig).toMatchObject({ port: 6514, protokoll: "tcp", appName: "vault_prod", facility: 19 });
|
||||||
|
expect(syslogZielText(konfig!)).toBe("tcp://10.0.0.5:6514");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("faellt bei Unsinn auf die Standards zurueck", () => {
|
||||||
|
expect(
|
||||||
|
syslogKonfigAus({
|
||||||
|
SSHKV_SYSLOG_HOST: "x",
|
||||||
|
SSHKV_SYSLOG_PORT: "99999",
|
||||||
|
SSHKV_SYSLOG_FACILITY: "quatsch",
|
||||||
|
}),
|
||||||
|
).toMatchObject({ port: 514, facility: 10, protokoll: "udp" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("syslogNachricht", () => {
|
||||||
|
it("baut eine Zeile nach RFC 5424 mit strukturierten Daten", () => {
|
||||||
|
expect(syslogNachricht(EINTRAG, KONFIG, FEST)).toBe(
|
||||||
|
"<85>1 2026-10-12T14:03:07.123Z vault sshkv 7 authorized_keys.geschrieben " +
|
||||||
|
'[sshkv@32473 id="42" akteur="Florian" aktion="authorized_keys.geschrieben" ergebnis="ERFOLG" ' +
|
||||||
|
'host="srv-pve01" zielbenutzer="root" fingerprint="SHA256:abc" hash="f00"] ' +
|
||||||
|
"Florian: authorized_keys.geschrieben ERFOLG auf srv-pve01",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leitet die Severity aus dem Ergebnis ab", () => {
|
||||||
|
// authpriv (10) * 8 = 80; notice 5, warning 4, err 3
|
||||||
|
expect(syslogNachricht({ ...EINTRAG, ergebnis: "VERWEIGERT" }, KONFIG, FEST)).toMatch(/^<84>1 /);
|
||||||
|
expect(syslogNachricht({ ...EINTRAG, ergebnis: "FEHLER" }, KONFIG, FEST)).toMatch(/^<83>1 /);
|
||||||
|
expect(syslogNachricht(EINTRAG, { appName: "sshkv", facility: 16 }, FEST)).toMatch(/^<133>1 /);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapt Anfuehrungszeichen, Backslash und eckige Klammer", () => {
|
||||||
|
expect(sdWertEscapen('a"b\\c]d')).toBe('a\\"b\\\\c\\]d');
|
||||||
|
const zeile = syslogNachricht({ ...EINTRAG, akteur: 'Eve "]x', zielbenutzer: "a\nb" }, KONFIG, FEST);
|
||||||
|
expect(zeile).toContain('akteur="Eve \\"\\]x"');
|
||||||
|
expect(zeile).toContain('zielbenutzer="a b"');
|
||||||
|
expect(zeile).not.toContain("\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("laesst leere Felder weg", () => {
|
||||||
|
const zeile = syslogNachricht(
|
||||||
|
{ ...EINTRAG, hostName: null, zielbenutzer: null, fingerprint: undefined },
|
||||||
|
KONFIG,
|
||||||
|
FEST,
|
||||||
|
);
|
||||||
|
expect(zeile).not.toContain("host=");
|
||||||
|
expect(zeile).not.toContain("fingerprint=");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bleibt unter der Hoechstlaenge, auch mit langen Werten", () => {
|
||||||
|
const lang = "ä".repeat(5000);
|
||||||
|
const zeile = syslogNachricht(
|
||||||
|
{ ...EINTRAG, akteur: lang, hostName: '"'.repeat(3000), zielbenutzer: lang, fingerprint: lang },
|
||||||
|
KONFIG,
|
||||||
|
FEST,
|
||||||
|
);
|
||||||
|
expect(Buffer.byteLength(zeile, "utf8")).toBeLessThanOrEqual(HOECHSTLAENGE);
|
||||||
|
// Strukturierte Daten bleiben vollstaendig und geschlossen.
|
||||||
|
expect(zeile).toMatch(/hash="f00"\] /);
|
||||||
|
// Kein halbes Escape am Ende eines Werts.
|
||||||
|
expect(zeile).not.toMatch(/[^\\]\\"\s/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rahmt fuer TCP mit Oktettzaehlung", () => {
|
||||||
|
expect(tcpRahmen("<85>1 ä").toString("utf8")).toBe("8 <85>1 ä");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("syslogSender", () => {
|
||||||
|
let empfaenger: Socket | undefined;
|
||||||
|
let server: Server | undefined;
|
||||||
|
afterEach(() => {
|
||||||
|
empfaenger?.close();
|
||||||
|
empfaenger = undefined;
|
||||||
|
server?.close();
|
||||||
|
server = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function udpEmpfaenger(): Promise<{ port: number; naechste: () => Promise<string> }> {
|
||||||
|
const socket = createSocket("udp4");
|
||||||
|
empfaenger = socket;
|
||||||
|
const eingang: string[] = [];
|
||||||
|
const wartende: ((text: string) => void)[] = [];
|
||||||
|
socket.on("message", (daten) => {
|
||||||
|
const text = daten.toString("utf8");
|
||||||
|
const warten = wartende.shift();
|
||||||
|
if (warten) warten(text);
|
||||||
|
else eingang.push(text);
|
||||||
|
});
|
||||||
|
await new Promise<void>((fertig) => socket.bind(0, "127.0.0.1", fertig));
|
||||||
|
return {
|
||||||
|
port: (socket.address() as AddressInfo).port,
|
||||||
|
naechste: () =>
|
||||||
|
new Promise((fertig) => {
|
||||||
|
const vorhanden = eingang.shift();
|
||||||
|
if (vorhanden) fertig(vorhanden);
|
||||||
|
else wartende.push(fertig);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("sendet per UDP an einen lokalen Empfaenger", async () => {
|
||||||
|
const { port, naechste } = await udpEmpfaenger();
|
||||||
|
const sender = syslogSender({
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port,
|
||||||
|
protokoll: "udp",
|
||||||
|
appName: "sshkv",
|
||||||
|
facility: 10,
|
||||||
|
});
|
||||||
|
sender.senden(EINTRAG);
|
||||||
|
const zeile = await naechste();
|
||||||
|
sender.schliessen();
|
||||||
|
expect(zeile).toMatch(
|
||||||
|
/^<85>1 2026-10-12T14:03:07.123Z \S+ sshkv \d+ authorized_keys.geschrieben \[sshkv@32473 /,
|
||||||
|
);
|
||||||
|
expect(zeile).toContain('hash="f00"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sendet per TCP mit Rahmen", async () => {
|
||||||
|
const eingang = new Promise<string>((fertig) => {
|
||||||
|
server = createServer((verbindung) => {
|
||||||
|
verbindung.on("data", (daten) => fertig(daten.toString("utf8")));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await new Promise<void>((fertig) => server!.listen(0, "127.0.0.1", fertig));
|
||||||
|
const port = (server!.address() as AddressInfo).port;
|
||||||
|
const sender = syslogSender({
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port,
|
||||||
|
protokoll: "tcp",
|
||||||
|
appName: "sshkv",
|
||||||
|
facility: 10,
|
||||||
|
});
|
||||||
|
sender.senden(EINTRAG);
|
||||||
|
const text = await eingang;
|
||||||
|
sender.schliessen();
|
||||||
|
const [laenge, ...rest] = text.split(" ");
|
||||||
|
expect(Number(laenge)).toBe(Buffer.byteLength(rest.join(" "), "utf8"));
|
||||||
|
expect(rest.join(" ")).toMatch(/^<85>1 /);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wirft nie und meldet Fehler hoechstens einmal je Minute", async () => {
|
||||||
|
const melden = vi.fn();
|
||||||
|
let uhr = 0;
|
||||||
|
// Port 1 auf localhost: niemand hoert zu, die Verbindung scheitert sofort.
|
||||||
|
const sender = syslogSender(
|
||||||
|
{ host: "127.0.0.1", port: 1, protokoll: "tcp", appName: "sshkv", facility: 10 },
|
||||||
|
melden,
|
||||||
|
() => uhr,
|
||||||
|
);
|
||||||
|
expect(() => sender.senden(EINTRAG)).not.toThrow();
|
||||||
|
await vi.waitFor(() => expect(melden).toHaveBeenCalledTimes(1));
|
||||||
|
uhr = 30_000;
|
||||||
|
sender.senden(EINTRAG);
|
||||||
|
await new Promise((fertig) => setTimeout(fertig, 100));
|
||||||
|
expect(melden).toHaveBeenCalledTimes(1);
|
||||||
|
uhr = 61_000;
|
||||||
|
sender.senden(EINTRAG);
|
||||||
|
await vi.waitFor(() => expect(melden).toHaveBeenCalledTimes(2));
|
||||||
|
sender.schliessen();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user