feat(benachrichtigung): E-Mail und Webhook bei Drift und Ablauf (#028)
Drift, ablaufende und abgelaufene Schluessel, unerreichbare Hosts und geaenderte Host-Keys sollen auffallen, ohne dass jemand taeglich in die Oberflaeche schaut. Versand per SMTP (Microsoft 365, STARTTLS) und per Webhook mit HMAC-Signatur, eingestellt unter /einstellungen. Die Anlaesse liest der Worker aus der Datenbank, statt sie im Abgleich auszuloesen: der Abgleich-Ablauf bleibt unberuehrt, und ein verpasster Lauf holt sich der naechste. Ein Versandprotokoll (Zustellung) mit Anlass-Fingerabdruck verhindert Doppelmeldungen und haelt Fehlschlaege fuer begrenzte Wiederholungen fest. Taeglich eine Zusammenfassung, geaenderte Host-Keys im Fuenf-Minuten-Takt sofort. Versand und Testnachricht laufen im Worker, damit der Webprozess keinen Weg zu Mailserver oder Webhook-Ziel braucht. Passwort und Geheimnis liegen nur verschluesselt in der Datenbank; die Seite zeigt "gesetzt". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,18 +1,196 @@
|
||||
import { Inbox } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
import type { ReactNode } from "react";
|
||||
import { BenachrichtigungFormular } from "@/components/einstellungen/BenachrichtigungFormular";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Karte } from "@/components/ui/Karte";
|
||||
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||
import { MonoKopieren } from "@/components/ui/MonoKopieren";
|
||||
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||
import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
|
||||
import { datumZeit } from "@/lib/format";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { einstellungenSeite, type Zeitplan, type ZustellungZeile } from "@/server/queries/einstellungen";
|
||||
|
||||
export const metadata: Metadata = { title: "Einstellungen" };
|
||||
|
||||
export default async function Einstellungen() {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
await sitzungErforderlich();
|
||||
function Info({ titel, children }: { titel: string; children: ReactNode }) {
|
||||
return (
|
||||
<Platzhalterseite
|
||||
titel="Einstellungen"
|
||||
untertitel="Deployment-Key, eigene Vorlagen, Benachrichtigungen"
|
||||
phase={3}
|
||||
issue="#019"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<dt className="text-muted text-[13px] font-medium">{titel}</dt>
|
||||
<dd className="text-text mt-1 text-[14.5px] break-words">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Abschnitt({ titel, id, children }: { titel: string; id: string; children: ReactNode }) {
|
||||
return (
|
||||
<section aria-labelledby={id} className="flex min-w-0 flex-col gap-4">
|
||||
<h2 id={id} className="text-muted text-[12.5px] font-semibold tracking-[0.08em] uppercase">
|
||||
{titel}
|
||||
</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ZeitplanAnzeige({ plan }: { plan: Zeitplan }) {
|
||||
return (
|
||||
<span className="flex flex-col gap-0.5">
|
||||
{plan.beschreibung && <span>{plan.beschreibung}</span>}
|
||||
<code className="text-muted font-mono text-[13px]">{plan.ausdruck}</code>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const KANAL_TEXT = { EMAIL: "E-Mail", WEBHOOK: "Webhook" } as const;
|
||||
|
||||
function Zustellungen({ zeilen }: { zeilen: readonly ZustellungZeile[] }) {
|
||||
if (zeilen.length === 0) {
|
||||
return (
|
||||
<Leerzustand
|
||||
icon={<Inbox className="size-5" />}
|
||||
titel="Noch nichts versendet"
|
||||
text="Hier erscheinen die letzten Zustellungen samt Ergebnis – auch Testnachrichten."
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tabelle
|
||||
beschriftung="Letzte Zustellungen"
|
||||
spalten={[{ titel: "Zeitpunkt" }, { titel: "Kanal" }, { titel: "Nachricht" }, { titel: "Ergebnis" }]}
|
||||
>
|
||||
{zeilen.map((z) => (
|
||||
<TabellenZeile key={z.sendung}>
|
||||
<TabellenZelle className="whitespace-nowrap">
|
||||
<time dateTime={z.zeitpunkt.toISOString()}>{datumZeit(z.zeitpunkt)}</time>
|
||||
</TabellenZelle>
|
||||
<TabellenZelle className="whitespace-nowrap">{KANAL_TEXT[z.kanal]}</TabellenZelle>
|
||||
<TabellenZelle className="min-w-[16rem]">
|
||||
<span className="block">{z.betreff}</span>
|
||||
<span className="text-muted block text-[12.5px]">
|
||||
{z.anlaesse === 1 ? "1 Anlass" : `${z.anlaesse} Anlässe`}
|
||||
{z.versuche > 1 && ` · ${z.versuche}. Versuch`}
|
||||
</span>
|
||||
</TabellenZelle>
|
||||
<TabellenZelle className="min-w-[12rem]">
|
||||
{z.ergebnis === "GESENDET" ? (
|
||||
<Badge variante="ok">gesendet</Badge>
|
||||
) : (
|
||||
<span className="flex flex-col items-start gap-1">
|
||||
<Badge variante="gefahr">fehlgeschlagen</Badge>
|
||||
{z.fehler && <span className="text-danger text-[12.5px] break-words">{z.fehler}</span>}
|
||||
<span className="text-muted text-[12.5px]">
|
||||
{z.naechsterVersuchAm
|
||||
? `neuer Versuch ab ${datumZeit(z.naechsterVersuchAm)}`
|
||||
: "kein weiterer Versuch"}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</TabellenZelle>
|
||||
</TabellenZeile>
|
||||
))}
|
||||
</Tabelle>
|
||||
);
|
||||
}
|
||||
|
||||
/** Einstellungen (#028): lesen duerfen alle, aendern nur `einstellungenVerwalten` (Admin). */
|
||||
export default async function Einstellungen() {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
const { benutzer } = await sitzungErforderlich("lesen", "/einstellungen");
|
||||
const darfAendern = darf(benutzer.rolle, "einstellungenVerwalten");
|
||||
const seite = await einstellungenSeite();
|
||||
// Eine Webhook-Adresse traegt oft ein Token im Pfad – Leser sehen nur Schema und Host.
|
||||
const ansicht = darfAendern
|
||||
? seite.benachrichtigung
|
||||
: {
|
||||
...seite.benachrichtigung,
|
||||
webhook: { ...seite.benachrichtigung.webhook, url: seite.webhookZiel ?? "" },
|
||||
};
|
||||
const { instanz } = seite;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
<Seitenkopf
|
||||
titel="Einstellungen"
|
||||
untertitel={
|
||||
darfAendern
|
||||
? "Benachrichtigungen, Zeitpläne und Angaben zu dieser Instanz"
|
||||
: "Nur Ansicht – ändern dürfen Administratoren."
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid min-w-0 gap-6 sm:gap-7 xl:grid-cols-[minmax(0,1fr)_22rem] xl:items-start">
|
||||
<Abschnitt titel="Benachrichtigungen" id="abschnitt-benachrichtigungen">
|
||||
<BenachrichtigungFormular ansicht={ansicht} darfAendern={darfAendern} />
|
||||
<Karte
|
||||
titel="Letzte Zustellungen"
|
||||
untertitel="Fehlgeschlagene Zustellungen wiederholt der Worker bis zu fünfmal."
|
||||
>
|
||||
<Zustellungen zeilen={seite.zustellungen} />
|
||||
</Karte>
|
||||
</Abschnitt>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-6 sm:gap-7">
|
||||
<Abschnitt titel="Abgleich" id="abschnitt-abgleich">
|
||||
<Karte as="div">
|
||||
<dl className="grid gap-4">
|
||||
<Info titel="Abgleich aller Hosts">
|
||||
<ZeitplanAnzeige plan={seite.abgleich} />
|
||||
</Info>
|
||||
<Info titel="Tägliche Zusammenfassung">
|
||||
<ZeitplanAnzeige plan={seite.meldung} />
|
||||
</Info>
|
||||
<Info titel="Geänderte Host-Keys">alle 5 Minuten geprüft</Info>
|
||||
</dl>
|
||||
<p className="text-muted mt-4 text-[13px]">
|
||||
Zeitzone Europe/Berlin. Gesetzt über <code className="font-mono">SSHKV_SYNC_CRON</code> und{" "}
|
||||
<code className="font-mono">SSHKV_MELDUNG_CRON</code> in der Umgebung des Workers.
|
||||
</p>
|
||||
</Karte>
|
||||
</Abschnitt>
|
||||
|
||||
{/* Platz fuer den Abschnitt „Syslog“ – folgt in einem eigenen Issue. */}
|
||||
|
||||
<Abschnitt titel="Instanz" id="abschnitt-instanz">
|
||||
<Karte as="div">
|
||||
<dl className="grid gap-4">
|
||||
<Info titel="Version">
|
||||
<span className="font-mono text-[13.5px]">{instanz.version}</span>
|
||||
</Info>
|
||||
<Info titel="Deployment-Key">
|
||||
{instanz.deploymentKey ? (
|
||||
<MonoKopieren
|
||||
wert={instanz.deploymentKey.fingerprint}
|
||||
beschriftung="Fingerprint des Deployment-Keys"
|
||||
umbrechen
|
||||
/>
|
||||
) : (
|
||||
<span className="text-accent">noch nicht erzeugt – läuft der Worker?</span>
|
||||
)}
|
||||
</Info>
|
||||
<Info titel="Anmeldung (OIDC)">
|
||||
{instanz.oidc.konfiguriert ? (
|
||||
instanz.oidc.name
|
||||
) : (
|
||||
<span className="text-muted">nicht eingerichtet</span>
|
||||
)}
|
||||
</Info>
|
||||
<Info titel="Notfall-Admin">
|
||||
{!instanz.notfall.vorhanden ? (
|
||||
<span className="text-muted">nicht angelegt</span>
|
||||
) : instanz.notfall.totpEingerichtet ? (
|
||||
<Badge variante="ok">TOTP eingerichtet</Badge>
|
||||
) : (
|
||||
<Badge variante="messing">TOTP noch nicht eingerichtet</Badge>
|
||||
)}
|
||||
</Info>
|
||||
</dl>
|
||||
</Karte>
|
||||
</Abschnitt>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
"use client";
|
||||
|
||||
import { Mail, Send, Webhook } from "lucide-react";
|
||||
import { startTransition, useActionState, useState, type FormEvent, type ReactNode } from "react";
|
||||
import {
|
||||
Ankreuzfeld,
|
||||
Badge,
|
||||
Button,
|
||||
Feld,
|
||||
Feldgruppe,
|
||||
Formularfehler,
|
||||
Karte,
|
||||
Textbereich,
|
||||
} from "@/components/ui";
|
||||
import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
|
||||
import type { BenachrichtigungAnsicht } from "@/lib/benachrichtigung/einstellungen";
|
||||
import { ANLAESSE, ANLASS_TEXT } from "@/lib/benachrichtigung/regeln";
|
||||
import { benachrichtigungSpeichernAktion, testSendenAktion } from "@/server/actions/einstellungen";
|
||||
import type { FormularZustand } from "@/server/actions/formular";
|
||||
|
||||
type Zustand = FormularZustand & { gespeichert?: boolean };
|
||||
|
||||
/** Fester Anfangszustand: ein neues `{}` je Render liesse den Vergleich unten nie zur Ruhe kommen. */
|
||||
const ANFANG: Zustand = {};
|
||||
|
||||
/**
|
||||
* Benachrichtigungskanaele und Anlaesse (#028). Geheimnisse kommen nie zurueck in den Browser:
|
||||
* die Felder starten leer, „gesetzt“ zeigt, dass ein Wert hinterlegt ist, und leer speichern
|
||||
* laesst ihn unveraendert.
|
||||
*/
|
||||
export function BenachrichtigungFormular({
|
||||
ansicht,
|
||||
darfAendern,
|
||||
}: {
|
||||
ansicht: BenachrichtigungAnsicht;
|
||||
darfAendern: boolean;
|
||||
}) {
|
||||
const [zustand, ausfuehren, laeuft] = useActionState<Zustand, FormData>(
|
||||
benachrichtigungSpeichernAktion,
|
||||
ANFANG,
|
||||
);
|
||||
// Nach dem Speichern neu aufbauen: Passwortfelder leeren sich, die Vorgaben kommen frisch
|
||||
// vom Server (revalidatePath). Ueber `key` statt Formular-Reset, damit Fehlerfaelle die
|
||||
// Eingaben behalten (Muster aus useFormularAktion, #008).
|
||||
const [fassung, setFassung] = useState(0);
|
||||
const [gemerkt, setGemerkt] = useState<Zustand>(zustand);
|
||||
if (zustand !== gemerkt) {
|
||||
setGemerkt(zustand);
|
||||
if (zustand.gespeichert) setFassung((f) => f + 1);
|
||||
}
|
||||
|
||||
function absenden(ereignis: FormEvent<HTMLFormElement>) {
|
||||
ereignis.preventDefault();
|
||||
const daten = new FormData(ereignis.currentTarget);
|
||||
startTransition(() => ausfuehren(daten));
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
key={fassung}
|
||||
onSubmit={absenden}
|
||||
noValidate
|
||||
className="flex flex-col gap-6"
|
||||
aria-label="Benachrichtigungen"
|
||||
>
|
||||
<Formularfehler text={zustand.fehler} />
|
||||
{zustand.gespeichert && !laeuft && (
|
||||
<p role="status" className="text-ok text-[14px]">
|
||||
Einstellungen gespeichert.
|
||||
</p>
|
||||
)}
|
||||
<Inhalt ansicht={ansicht} darfAendern={darfAendern} f={zustand.feldfehler ?? {}} />
|
||||
{darfAendern && (
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||||
{laeuft ? "Speichere …" : "Benachrichtigungen speichern"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Inhalt({
|
||||
ansicht,
|
||||
darfAendern,
|
||||
f,
|
||||
}: {
|
||||
ansicht: BenachrichtigungAnsicht;
|
||||
darfAendern: boolean;
|
||||
f: Record<string, string>;
|
||||
}) {
|
||||
const [sicherheit, setSicherheit] = useState(ansicht.email.sicherheit);
|
||||
const [httpErlaubt, setHttpErlaubt] = useState(ansicht.webhook.httpErlaubt);
|
||||
const { email, webhook } = ansicht;
|
||||
|
||||
return (
|
||||
// Leser sehen dieselben Felder, aber gesperrt und ohne Knoepfe.
|
||||
<fieldset disabled={!darfAendern} className="flex min-w-0 flex-col gap-6">
|
||||
<Karte
|
||||
titel={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Mail className="text-muted size-4" aria-hidden /> E-Mail (SMTP)
|
||||
</span>
|
||||
}
|
||||
untertitel="Vorbelegt für Microsoft 365: smtp.office365.com, Port 587, STARTTLS."
|
||||
aktionen={<KanalStatus aktiv={email.aktiv} />}
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
<Ankreuzfeld
|
||||
name="emailAktiv"
|
||||
value="ja"
|
||||
defaultChecked={email.aktiv}
|
||||
beschriftung="Per E-Mail benachrichtigen"
|
||||
/>
|
||||
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_6.5rem] gap-3">
|
||||
<Feld
|
||||
beschriftung="Server"
|
||||
name="emailServer"
|
||||
defaultValue={email.server}
|
||||
mono
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
fehler={f["emailServer"]}
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Port"
|
||||
name="emailPort"
|
||||
defaultValue={email.port}
|
||||
mono
|
||||
inputMode="numeric"
|
||||
fehler={f["emailPort"]}
|
||||
/>
|
||||
</div>
|
||||
<Feldgruppe legende="Verschlüsselung" fehler={f["emailSicherheit"]}>
|
||||
<div className="grid gap-x-4 sm:grid-cols-3">
|
||||
{(
|
||||
[
|
||||
["STARTTLS", "STARTTLS", "Port 587 – Microsoft 365"],
|
||||
["TLS", "TLS (SMTPS)", "Port 465"],
|
||||
["KEINE", "Unverschlüsselt", "nur internes Relay"],
|
||||
] as const
|
||||
).map(([wert, titel, erklaerung]) => (
|
||||
<Ankreuzfeld
|
||||
key={wert}
|
||||
type="radio"
|
||||
name="emailSicherheit"
|
||||
value={wert}
|
||||
defaultChecked={email.sicherheit === wert}
|
||||
onChange={() => setSicherheit(wert)}
|
||||
beschriftung={titel}
|
||||
erklaerung={erklaerung}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{sicherheit === "KEINE" && (
|
||||
<Warnung>
|
||||
Ohne Verschlüsselung gehen Zugangsdaten und Inhalt im Klartext über das Netz. Nur für ein
|
||||
Relay im selben, vertrauenswürdigen Netz verwenden.
|
||||
</Warnung>
|
||||
)}
|
||||
</Feldgruppe>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld
|
||||
beschriftung="Absender"
|
||||
name="emailAbsender"
|
||||
type="email"
|
||||
defaultValue={email.absender}
|
||||
autoComplete="off"
|
||||
placeholder="sshkv@example.de"
|
||||
fehler={f["emailAbsender"]}
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Benutzer (optional)"
|
||||
name="emailBenutzer"
|
||||
defaultValue={email.benutzer}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
hinweis="Bei Microsoft 365 meist die Absenderadresse."
|
||||
fehler={f["emailBenutzer"]}
|
||||
/>
|
||||
</div>
|
||||
<GeheimFeld
|
||||
beschriftung="Passwort"
|
||||
name="emailPasswort"
|
||||
gesetzt={email.passwortGesetzt}
|
||||
darfAendern={darfAendern}
|
||||
fehler={f["emailPasswort"]}
|
||||
/>
|
||||
<Textbereich
|
||||
beschriftung="Empfänger"
|
||||
name="emailEmpfaenger"
|
||||
defaultValue={email.empfaenger.join("\n")}
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
placeholder="admin@example.de"
|
||||
hinweis="Eine Adresse je Zeile oder durch Komma getrennt."
|
||||
fehler={f["emailEmpfaenger"]}
|
||||
/>
|
||||
{darfAendern && <TestKnopf kanal="EMAIL" />}
|
||||
</div>
|
||||
</Karte>
|
||||
|
||||
<Karte
|
||||
titel={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Webhook className="text-muted size-4" aria-hidden /> Webhook
|
||||
</span>
|
||||
}
|
||||
untertitel="JSON per POST, optional signiert mit X-Sshkv-Signatur: sha256=<HMAC des Bodys>."
|
||||
aktionen={<KanalStatus aktiv={webhook.aktiv} />}
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
<Ankreuzfeld
|
||||
name="webhookAktiv"
|
||||
value="ja"
|
||||
defaultChecked={webhook.aktiv}
|
||||
beschriftung="Per Webhook benachrichtigen"
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Adresse"
|
||||
name="webhookUrl"
|
||||
type="url"
|
||||
defaultValue={webhook.url}
|
||||
mono
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="https://alarm.example.de/hooks/sshkv"
|
||||
fehler={f["webhookUrl"]}
|
||||
/>
|
||||
<div>
|
||||
<Ankreuzfeld
|
||||
name="webhookHttpErlaubt"
|
||||
value="ja"
|
||||
defaultChecked={webhook.httpErlaubt}
|
||||
onChange={(e) => setHttpErlaubt(e.currentTarget.checked)}
|
||||
beschriftung="http:// für ein internes Ziel erlauben"
|
||||
erklaerung="Sonst nimmt sshkv nur https://-Adressen an."
|
||||
/>
|
||||
{httpErlaubt && (
|
||||
<Warnung>
|
||||
Über http:// sind Nutzlast und Signatur unterwegs lesbar und veränderbar. Nur für Ziele im
|
||||
selben, vertrauenswürdigen Netz.
|
||||
</Warnung>
|
||||
)}
|
||||
</div>
|
||||
<GeheimFeld
|
||||
beschriftung="Geheimnis für die Signatur (optional)"
|
||||
name="webhookGeheimnis"
|
||||
gesetzt={webhook.geheimnisGesetzt}
|
||||
darfAendern={darfAendern}
|
||||
fehler={f["webhookGeheimnis"]}
|
||||
/>
|
||||
{darfAendern && <TestKnopf kanal="WEBHOOK" />}
|
||||
</div>
|
||||
</Karte>
|
||||
|
||||
<Karte
|
||||
titel="Anlässe"
|
||||
untertitel="Drift, Ablauf und Erreichbarkeit kommen gesammelt in der täglichen Zusammenfassung; ein geänderter Host-Key sofort."
|
||||
>
|
||||
<Feldgruppe legende="Benachrichtigen bei" fehler={f["anlaesse"]}>
|
||||
<div className="grid gap-x-4 sm:grid-cols-2">
|
||||
{ANLAESSE.map((anlass) => (
|
||||
<Ankreuzfeld
|
||||
key={anlass}
|
||||
name="anlaesse"
|
||||
value={anlass}
|
||||
defaultChecked={ansicht.anlaesse[anlass]}
|
||||
beschriftung={ANLASS_TEXT[anlass].titel}
|
||||
erklaerung={ANLASS_TEXT[anlass].erklaerung}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Feldgruppe>
|
||||
</Karte>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function KanalStatus({ aktiv }: { aktiv: boolean }) {
|
||||
return aktiv ? <Badge variante="ok">aktiv</Badge> : <Badge>aus</Badge>;
|
||||
}
|
||||
|
||||
function Warnung({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<p
|
||||
role="note"
|
||||
className="border-accent/40 bg-accent-soft text-accent rounded-box mt-2 border px-3 py-2 text-[13.5px]"
|
||||
>
|
||||
▲ {children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwort bzw. Geheimnis: nie vorbelegt. Ist ein Wert gesetzt, zeigt das Feld nur „gesetzt“;
|
||||
* leer lassen behaelt ihn, das Ankreuzfeld entfernt ihn.
|
||||
*/
|
||||
function GeheimFeld({
|
||||
beschriftung,
|
||||
name,
|
||||
gesetzt,
|
||||
darfAendern,
|
||||
fehler,
|
||||
}: {
|
||||
beschriftung: string;
|
||||
name: string;
|
||||
gesetzt: boolean;
|
||||
darfAendern: boolean;
|
||||
fehler?: string | undefined;
|
||||
}) {
|
||||
if (!darfAendern) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<span className="text-muted text-[13px] font-medium">{beschriftung}</span>
|
||||
<span>{gesetzt ? <Badge variante="ok">gesetzt</Badge> : <Badge>nicht gesetzt</Badge>}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<Feld
|
||||
beschriftung={beschriftung}
|
||||
name={name}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
defaultValue=""
|
||||
placeholder={gesetzt ? "gesetzt – leer lassen, um es zu behalten" : "nicht gesetzt"}
|
||||
hinweis={
|
||||
gesetzt ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Badge variante="ok">gesetzt</Badge> Wird verschlüsselt gespeichert und nie wieder angezeigt.
|
||||
</span>
|
||||
) : (
|
||||
"Wird verschlüsselt gespeichert und nie wieder angezeigt."
|
||||
)
|
||||
}
|
||||
fehler={fehler}
|
||||
/>
|
||||
{gesetzt && (
|
||||
<Ankreuzfeld
|
||||
name={`${name}Entfernen`}
|
||||
value="ja"
|
||||
beschriftung={`${beschriftung.replace(/ \(optional\)$/, "")} entfernen`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TestKnopf({ kanal }: { kanal: "EMAIL" | "WEBHOOK" }) {
|
||||
return (
|
||||
<div className="border-border flex flex-col gap-2 border-t pt-4">
|
||||
<p className="text-muted text-[13px]">
|
||||
Die Testnachricht nutzt den <strong className="text-text font-medium">gespeicherten</strong> Stand –
|
||||
erst speichern, dann testen. Der Versand läuft im Worker.
|
||||
</p>
|
||||
<VorgangsKnopf
|
||||
aktion={() => testSendenAktion(kanal)}
|
||||
beschriftung="Testnachricht senden"
|
||||
icon={<Send className="size-4" />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { z } from "@/lib/validierung";
|
||||
import { ANLAESSE, webhookUrlPruefen, type AnlassArt } from "./regeln";
|
||||
|
||||
/**
|
||||
* Form der Benachrichtigungs-Einstellungen (#028). Gespeichert als JSON in `Einstellung`
|
||||
* unter EINSTELLUNG_BENACHRICHTIGUNG; Passwort und Webhook-Geheimnis liegen dort nur
|
||||
* verschluesselt (`verschluesseln()` mit SSHKV_APP_SECRET).
|
||||
*/
|
||||
export const EINSTELLUNG_BENACHRICHTIGUNG = "benachrichtigungen";
|
||||
|
||||
/** STARTTLS ist der Weg von Microsoft 365 (Port 587); KEINE nur fuer interne Relays. */
|
||||
export const SMTP_SICHERHEIT = ["STARTTLS", "TLS", "KEINE"] as const;
|
||||
export type SmtpSicherheit = (typeof SMTP_SICHERHEIT)[number];
|
||||
|
||||
export type GespeicherteBenachrichtigung = {
|
||||
email: {
|
||||
aktiv: boolean;
|
||||
server: string;
|
||||
port: number;
|
||||
sicherheit: SmtpSicherheit;
|
||||
absender: string;
|
||||
benutzer: string;
|
||||
/** Chiffrat aus `verschluesseln()` oder null. */
|
||||
passwort: string | null;
|
||||
empfaenger: string[];
|
||||
};
|
||||
webhook: {
|
||||
aktiv: boolean;
|
||||
url: string;
|
||||
httpErlaubt: boolean;
|
||||
/** Chiffrat aus `verschluesseln()` oder null. */
|
||||
geheimnis: string | null;
|
||||
};
|
||||
anlaesse: Record<AnlassArt, boolean>;
|
||||
};
|
||||
|
||||
/** Vorbelegung fuer Microsoft 365 – alles aus, bis ein Admin es einschaltet. */
|
||||
export const STANDARD_BENACHRICHTIGUNG: GespeicherteBenachrichtigung = {
|
||||
email: {
|
||||
aktiv: false,
|
||||
server: "smtp.office365.com",
|
||||
port: 587,
|
||||
sicherheit: "STARTTLS",
|
||||
absender: "",
|
||||
benutzer: "",
|
||||
passwort: null,
|
||||
empfaenger: [],
|
||||
},
|
||||
webhook: { aktiv: false, url: "", httpErlaubt: false, geheimnis: null },
|
||||
anlaesse: { drift: true, ablauf: true, abgelaufen: true, unerreichbar: true, hostkey: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* Was die Oberflaeche sehen darf: statt der Geheimnisse nur, ob sie gesetzt sind. Auch das
|
||||
* Chiffrat geht nicht in Client-Props – es waere mit SSHKV_APP_SECRET lesbar.
|
||||
*/
|
||||
export type BenachrichtigungAnsicht = {
|
||||
email: Omit<GespeicherteBenachrichtigung["email"], "passwort"> & { passwortGesetzt: boolean };
|
||||
webhook: Omit<GespeicherteBenachrichtigung["webhook"], "geheimnis"> & { geheimnisGesetzt: boolean };
|
||||
anlaesse: Record<AnlassArt, boolean>;
|
||||
};
|
||||
|
||||
export function alsAnsicht(gespeichert: GespeicherteBenachrichtigung): BenachrichtigungAnsicht {
|
||||
const { passwort, ...email } = gespeichert.email;
|
||||
const { geheimnis, ...webhook } = gespeichert.webhook;
|
||||
return {
|
||||
email: { ...email, passwortGesetzt: passwort !== null },
|
||||
webhook: { ...webhook, geheimnisGesetzt: geheimnis !== null },
|
||||
anlaesse: { ...gespeichert.anlaesse },
|
||||
};
|
||||
}
|
||||
|
||||
/** Liest gespeichertes JSON tolerant: fehlende Felder bekommen die Vorbelegung. */
|
||||
export function gespeichertLesen(wert: unknown): GespeicherteBenachrichtigung {
|
||||
const roh = (
|
||||
typeof wert === "object" && wert !== null ? wert : {}
|
||||
) as Partial<GespeicherteBenachrichtigung>;
|
||||
const s = STANDARD_BENACHRICHTIGUNG;
|
||||
return {
|
||||
email: { ...s.email, ...(roh.email ?? {}) },
|
||||
webhook: { ...s.webhook, ...(roh.webhook ?? {}) },
|
||||
anlaesse: { ...s.anlaesse, ...(roh.anlaesse ?? {}) },
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Formular
|
||||
|
||||
/** Empfaengerliste: Komma, Semikolon, Leerzeichen oder Zeilenumbruch trennen. */
|
||||
export function empfaengerAus(text: string): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
text
|
||||
.split(/[\s,;]+/)
|
||||
.map((e) => e.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const email = z.email({ error: "Keine gültige E-Mail-Adresse." });
|
||||
|
||||
export const benachrichtigungSchema = z
|
||||
.object({
|
||||
emailAktiv: z.boolean(),
|
||||
emailServer: z.string().trim().max(253),
|
||||
emailPort: z.coerce.number().int().min(1, "Port 1–65535.").max(65535, "Port 1–65535."),
|
||||
emailSicherheit: z.enum(SMTP_SICHERHEIT),
|
||||
emailAbsender: z.string().trim().max(254),
|
||||
emailBenutzer: z.string().trim().max(254),
|
||||
/** Leer = unveraendert lassen. */
|
||||
emailPasswort: z.string().max(1024).optional(),
|
||||
emailPasswortEntfernen: z.boolean(),
|
||||
emailEmpfaenger: z.array(z.string()).max(50, "Höchstens 50 Empfänger."),
|
||||
webhookAktiv: z.boolean(),
|
||||
webhookUrl: z.string().trim().max(2048),
|
||||
webhookHttpErlaubt: z.boolean(),
|
||||
webhookGeheimnis: z.string().max(1024).optional(),
|
||||
webhookGeheimnisEntfernen: z.boolean(),
|
||||
anlaesse: z.array(z.enum(ANLAESSE)),
|
||||
})
|
||||
.superRefine((e, ctx) => {
|
||||
const fehler = (pfad: string, message: string) => ctx.addIssue({ code: "custom", path: [pfad], message });
|
||||
if (e.emailAktiv && !e.emailServer) fehler("emailServer", "Server angeben.");
|
||||
if (e.emailServer && !/^[A-Za-z0-9.-]+$/.test(e.emailServer))
|
||||
fehler("emailServer", "Nur Hostname oder IP.");
|
||||
if (e.emailAktiv && !e.emailAbsender) fehler("emailAbsender", "Absender angeben.");
|
||||
if (e.emailAbsender && !email.safeParse(e.emailAbsender).success)
|
||||
fehler("emailAbsender", "Keine gültige E-Mail-Adresse.");
|
||||
if (e.emailAktiv && e.emailEmpfaenger.length === 0)
|
||||
fehler("emailEmpfaenger", "Mindestens ein Empfänger.");
|
||||
const falsch = e.emailEmpfaenger.find((a) => !email.safeParse(a).success);
|
||||
if (falsch) fehler("emailEmpfaenger", `„${falsch.slice(0, 80)}“ ist keine gültige E-Mail-Adresse.`);
|
||||
if (e.webhookAktiv && !e.webhookUrl) fehler("webhookUrl", "Adresse angeben.");
|
||||
if (e.webhookUrl) {
|
||||
const meldung = webhookUrlPruefen(e.webhookUrl, e.webhookHttpErlaubt);
|
||||
if (meldung) fehler("webhookUrl", meldung);
|
||||
}
|
||||
});
|
||||
|
||||
export type BenachrichtigungEingabe = z.input<typeof benachrichtigungSchema>;
|
||||
export type BenachrichtigungDaten = z.output<typeof benachrichtigungSchema>;
|
||||
@@ -0,0 +1,346 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
import { datum, datumZeit } from "@/lib/format";
|
||||
import { PRODUKTNAME } from "@/lib/version";
|
||||
|
||||
/**
|
||||
* Nachrichtenaufbau der Benachrichtigungen (#028): Betreff, Text- und HTML-Mail auf Deutsch
|
||||
* und die JSON-Nutzlast des Webhooks – aus denselben Daten, damit beide Wege dasselbe sagen.
|
||||
*
|
||||
* Alles, was in HTML landet, stammt teils von den Hosts (Kommentare in authorized_keys,
|
||||
* Benutzernamen) und wird deshalb maskiert. Schlichtes HTML mit Inline-Stilen, weil
|
||||
* E-Mail-Programme Stylesheets ignorieren (Muster aus ProxLedger).
|
||||
*/
|
||||
|
||||
export const ART_TEXT: Readonly<Record<string, string>> = {
|
||||
UNBEKANNTER_SCHLUESSEL: "Unbekannter Schlüssel",
|
||||
FEHLENDER_SCHLUESSEL: "Schlüssel fehlt",
|
||||
ZUSAETZLICH_IM_BLOCK: "Block von Hand geändert",
|
||||
DATEI_GEAENDERT: "Block beschädigt",
|
||||
RECHTE: "Rechte",
|
||||
HOSTKEY_GEAENDERT: "Host-Key geändert",
|
||||
PRIVATE_KEY_FEHLT: "Private Key fehlt/verändert",
|
||||
};
|
||||
|
||||
export type Befund = {
|
||||
readonly art: string;
|
||||
readonly zielbenutzer: string | null;
|
||||
readonly fingerprint: string | null;
|
||||
readonly text: string | null;
|
||||
};
|
||||
|
||||
export type HostDrift = {
|
||||
readonly hostId: string;
|
||||
readonly name: string;
|
||||
readonly adresse: string;
|
||||
readonly befunde: readonly Befund[];
|
||||
};
|
||||
|
||||
export type HostUnerreichbar = {
|
||||
readonly hostId: string;
|
||||
readonly name: string;
|
||||
readonly adresse: string;
|
||||
readonly zuletztGeprueftAm: Date | null;
|
||||
readonly letzterAbgleichAm: Date | null;
|
||||
};
|
||||
|
||||
export type SchluesselAblauf = {
|
||||
readonly schluesselId: string;
|
||||
readonly name: string;
|
||||
readonly fingerprint: string;
|
||||
readonly ablaufAm: Date;
|
||||
readonly tage: number;
|
||||
readonly stufe: number;
|
||||
};
|
||||
|
||||
export type SchluesselAbgelaufen = {
|
||||
readonly schluesselId: string;
|
||||
readonly name: string;
|
||||
readonly fingerprint: string;
|
||||
readonly ablaufAm: Date;
|
||||
readonly hosts: readonly string[];
|
||||
};
|
||||
|
||||
export type Zusammenfassung = {
|
||||
readonly drift: readonly HostDrift[];
|
||||
readonly unerreichbar: readonly HostUnerreichbar[];
|
||||
readonly ablauf: readonly SchluesselAblauf[];
|
||||
readonly abgelaufen: readonly SchluesselAbgelaufen[];
|
||||
};
|
||||
|
||||
export type HostKeyAenderung = {
|
||||
readonly hostId: string;
|
||||
readonly name: string;
|
||||
readonly adresse: string;
|
||||
readonly befunde: readonly { algorithmus: string; erwartet: string | null; gesehen: string }[];
|
||||
};
|
||||
|
||||
export type WebhookNutzlast = {
|
||||
readonly ereignis: "zusammenfassung" | "hostkey.geaendert" | "test";
|
||||
readonly zeitstempel: string;
|
||||
readonly instanz: string;
|
||||
readonly [weiteres: string]: unknown;
|
||||
};
|
||||
|
||||
export type Nachricht = {
|
||||
readonly betreff: string;
|
||||
readonly text: string;
|
||||
readonly html: string;
|
||||
readonly nutzlast: WebhookNutzlast;
|
||||
};
|
||||
|
||||
export type Rahmen = { readonly appUrl: string; readonly jetzt: Date };
|
||||
|
||||
// ---------------------------------------------------------------- Hilfen
|
||||
|
||||
export function html(text: string): string {
|
||||
return text
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function basis(appUrl: string): string {
|
||||
return appUrl.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function mehrzahl(anzahl: number, eins: string, viele: string): string {
|
||||
return `${anzahl} ${anzahl === 1 ? eins : viele}`;
|
||||
}
|
||||
|
||||
function htmlRahmen(inhalt: string, appUrl: string, linkText: string, pfad: string): string {
|
||||
return `<!doctype html><html lang="de"><body style="margin:0;padding:24px;background:#ffffff">
|
||||
<div style="font-family:'IBM Plex Sans',Arial,sans-serif;font-size:15px;line-height:1.5;color:#1d232b;max-width:680px">
|
||||
<div style="border-bottom:3px solid #b08a3e;padding-bottom:8px;margin-bottom:16px">
|
||||
<span style="font-size:20px;font-weight:600">${PRODUKTNAME}</span>
|
||||
<span style="color:#5d6672"> · Benachrichtigung</span>
|
||||
</div>
|
||||
${inhalt}
|
||||
<p style="color:#5d6672;font-size:13px;margin-top:24px">
|
||||
<a href="${html(basis(appUrl) + pfad)}" style="color:#8a6a2a">${html(linkText)}</a>
|
||||
</p>
|
||||
</div></body></html>`;
|
||||
}
|
||||
|
||||
function befundZeile(b: Befund): string {
|
||||
const teile = [ART_TEXT[b.art] ?? b.art];
|
||||
if (b.zielbenutzer) teile.push(`Benutzer ${b.zielbenutzer}`);
|
||||
if (b.fingerprint) teile.push(b.fingerprint);
|
||||
if (b.text) teile.push(b.text);
|
||||
return teile.join(" – ");
|
||||
}
|
||||
|
||||
function befundJson(b: Befund) {
|
||||
return {
|
||||
art: b.art,
|
||||
artText: ART_TEXT[b.art] ?? b.art,
|
||||
zielbenutzer: b.zielbenutzer,
|
||||
fingerprint: b.fingerprint,
|
||||
text: b.text,
|
||||
};
|
||||
}
|
||||
|
||||
const H3 = 'style="font-size:16px;margin:20px 0 6px"';
|
||||
const UL = 'style="margin:4px 0 12px;padding-left:20px"';
|
||||
const MONO = "font-family:'JetBrains Mono',Consolas,monospace;font-size:13px";
|
||||
|
||||
// ---------------------------------------------------------------- Zusammenfassung
|
||||
|
||||
export function zusammenfassungLeer(z: Zusammenfassung): boolean {
|
||||
return z.drift.length + z.unerreichbar.length + z.ablauf.length + z.abgelaufen.length === 0;
|
||||
}
|
||||
|
||||
/** Tägliche Zusammenfassung der Anlaesse (a)–(d). */
|
||||
export function zusammenfassungNachricht(z: Zusammenfassung, rahmen: Rahmen): Nachricht {
|
||||
const befundAnzahl = z.drift.reduce((summe, h) => summe + h.befunde.length, 0);
|
||||
const kopf: string[] = [];
|
||||
if (befundAnzahl > 0) kopf.push(mehrzahl(befundAnzahl, "neue Abweichung", "neue Abweichungen"));
|
||||
if (z.unerreichbar.length > 0)
|
||||
kopf.push(mehrzahl(z.unerreichbar.length, "Host nicht erreichbar", "Hosts nicht erreichbar"));
|
||||
if (z.ablauf.length > 0) kopf.push(mehrzahl(z.ablauf.length, "Schlüssel läuft ab", "Schlüssel laufen ab"));
|
||||
if (z.abgelaufen.length > 0)
|
||||
kopf.push(
|
||||
mehrzahl(z.abgelaufen.length, "abgelaufener Schlüssel ausgerollt", "abgelaufene Schlüssel ausgerollt"),
|
||||
);
|
||||
const betreff = `[sshkv] Zusammenfassung: ${kopf.join(", ") || "keine neuen Meldungen"}`;
|
||||
const url = basis(rahmen.appUrl);
|
||||
|
||||
const text: string[] = [`${PRODUKTNAME} – Zusammenfassung vom ${datumZeit(rahmen.jetzt)}`, ""];
|
||||
const teile: string[] = [
|
||||
`<h2 style="font-size:18px;margin:0 0 8px">Zusammenfassung vom ${html(datumZeit(rahmen.jetzt))}</h2>`,
|
||||
];
|
||||
|
||||
if (z.drift.length > 0) {
|
||||
text.push("Neue Abweichungen (Drift):");
|
||||
teile.push(`<h3 ${H3}>Neue Abweichungen (Drift)</h3>`);
|
||||
for (const host of z.drift) {
|
||||
text.push(` ${host.name} (${host.adresse}):`);
|
||||
for (const b of host.befunde) text.push(` · ${befundZeile(b)}`);
|
||||
text.push(` ${url}/hosts/${host.hostId}`);
|
||||
teile.push(
|
||||
`<p style="margin:8px 0 2px"><a href="${html(`${url}/hosts/${host.hostId}`)}" style="color:#1d232b;font-weight:600;${MONO}">${html(host.name)}</a> <span style="color:#5d6672">${html(host.adresse)}</span></p>`,
|
||||
`<ul ${UL}>${host.befunde.map((b) => `<li>${html(befundZeile(b))}</li>`).join("")}</ul>`,
|
||||
);
|
||||
}
|
||||
text.push("");
|
||||
}
|
||||
|
||||
if (z.unerreichbar.length > 0) {
|
||||
text.push("Hosts nicht erreichbar:");
|
||||
teile.push(`<h3 ${H3}>Hosts nicht erreichbar</h3><ul ${UL}>`);
|
||||
for (const host of z.unerreichbar) {
|
||||
const zuletzt = host.letzterAbgleichAm
|
||||
? `letzter erfolgreicher Abgleich ${datumZeit(host.letzterAbgleichAm)}`
|
||||
: "noch nie erfolgreich abgeglichen";
|
||||
text.push(` · ${host.name} (${host.adresse}) – ${zuletzt}`);
|
||||
teile.push(
|
||||
`<li><span style="${MONO}">${html(host.name)}</span> (${html(host.adresse)}) – ${html(zuletzt)}</li>`,
|
||||
);
|
||||
}
|
||||
teile.push("</ul>");
|
||||
text.push("");
|
||||
}
|
||||
|
||||
if (z.ablauf.length > 0) {
|
||||
text.push("Bald ablaufende Schlüssel:");
|
||||
teile.push(`<h3 ${H3}>Bald ablaufende Schlüssel</h3><ul ${UL}>`);
|
||||
for (const s of z.ablauf) {
|
||||
const wann = `läuft am ${datum(s.ablaufAm)} ab (${s.tage === 1 ? "in 1 Tag" : `in ${s.tage} Tagen`})`;
|
||||
text.push(` · ${s.name} – ${wann}`, ` ${s.fingerprint}`);
|
||||
teile.push(
|
||||
`<li>${html(s.name)} – ${html(wann)}<br><span style="color:#5d6672;${MONO}">${html(s.fingerprint)}</span></li>`,
|
||||
);
|
||||
}
|
||||
teile.push("</ul>");
|
||||
text.push("");
|
||||
}
|
||||
|
||||
if (z.abgelaufen.length > 0) {
|
||||
text.push("Abgelaufen, aber noch ausgerollt:");
|
||||
teile.push(`<h3 ${H3}>Abgelaufen, aber noch ausgerollt</h3><ul ${UL}>`);
|
||||
for (const s of z.abgelaufen) {
|
||||
const wo = s.hosts.length > 0 ? `noch auf ${s.hosts.join(", ")}` : "noch ausgerollt";
|
||||
text.push(` · ${s.name} – abgelaufen am ${datum(s.ablaufAm)}, ${wo}`, ` ${s.fingerprint}`);
|
||||
teile.push(
|
||||
`<li>${html(s.name)} – abgelaufen am ${html(datum(s.ablaufAm))}, ${html(wo)}<br><span style="color:#5d6672;${MONO}">${html(s.fingerprint)}</span></li>`,
|
||||
);
|
||||
}
|
||||
teile.push("</ul>");
|
||||
text.push("");
|
||||
}
|
||||
|
||||
text.push(`Übersicht: ${url}/`);
|
||||
|
||||
return {
|
||||
betreff,
|
||||
text: text.join("\n"),
|
||||
html: htmlRahmen(teile.join("\n"), rahmen.appUrl, "Übersicht öffnen", "/"),
|
||||
nutzlast: {
|
||||
ereignis: "zusammenfassung",
|
||||
zeitstempel: rahmen.jetzt.toISOString(),
|
||||
instanz: url,
|
||||
drift: z.drift.map((h) => ({
|
||||
host: { id: h.hostId, name: h.name, adresse: h.adresse },
|
||||
befunde: h.befunde.map(befundJson),
|
||||
})),
|
||||
unerreichbar: z.unerreichbar.map((h) => ({
|
||||
host: { id: h.hostId, name: h.name, adresse: h.adresse },
|
||||
zuletztGeprueftAm: h.zuletztGeprueftAm?.toISOString() ?? null,
|
||||
letzterAbgleichAm: h.letzterAbgleichAm?.toISOString() ?? null,
|
||||
})),
|
||||
ablauf: z.ablauf.map((s) => ({
|
||||
schluessel: { id: s.schluesselId, name: s.name, fingerprint: s.fingerprint },
|
||||
ablaufAm: s.ablaufAm.toISOString(),
|
||||
tage: s.tage,
|
||||
stufe: s.stufe,
|
||||
})),
|
||||
abgelaufen: z.abgelaufen.map((s) => ({
|
||||
schluessel: { id: s.schluesselId, name: s.name, fingerprint: s.fingerprint },
|
||||
ablaufAm: s.ablaufAm.toISOString(),
|
||||
hosts: [...s.hosts],
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Host-Key (sofort)
|
||||
|
||||
/** Dringende Einzelmeldung (e) je Host. */
|
||||
export function hostKeyNachricht(h: HostKeyAenderung, rahmen: Rahmen): Nachricht {
|
||||
const url = `${basis(rahmen.appUrl)}/hosts/${h.hostId}`;
|
||||
const betreff = `[sshkv] DRINGEND: Host-Key von ${h.name} hat sich geändert`;
|
||||
const zeilen = h.befunde.map(
|
||||
(b) => `${b.algorithmus}: erwartet ${b.erwartet ?? "(unbekannt)"}, gesehen ${b.gesehen}`,
|
||||
);
|
||||
const text = [
|
||||
`Der Host-Key von ${h.name} (${h.adresse}) hat sich geändert.`,
|
||||
"",
|
||||
...zeilen.map((z) => ` · ${z}`),
|
||||
"",
|
||||
"sshkv verbindet sich mit diesem Host nicht mehr, bis jemand den neuen Key geprüft und",
|
||||
"bestätigt hat. Ist die Änderung nicht erklärbar (Neuinstallation, Austausch), kann ein",
|
||||
"Angreifer zwischen sshkv und dem Host sitzen.",
|
||||
"",
|
||||
url,
|
||||
].join("\n");
|
||||
const inhalt = `<p style="margin:0 0 8px"><span style="display:inline-block;padding:2px 10px;border-radius:999px;background:#b3261e;color:#ffffff;font-size:13px;font-weight:600">Dringend</span></p>
|
||||
<h2 style="font-size:18px;margin:8px 0">Host-Key von <span style="${MONO};font-size:17px">${html(h.name)}</span> geändert</h2>
|
||||
<p style="margin:0 0 8px">${html(h.adresse)}</p>
|
||||
<ul ${UL}>${zeilen.map((z) => `<li style="${MONO}">${html(z)}</li>`).join("")}</ul>
|
||||
<p style="margin:0">sshkv verbindet sich mit diesem Host nicht mehr, bis jemand den neuen Key geprüft und bestätigt hat. Ist die Änderung nicht erklärbar (Neuinstallation, Austausch), kann ein Angreifer zwischen sshkv und dem Host sitzen.</p>`;
|
||||
return {
|
||||
betreff,
|
||||
text,
|
||||
html: htmlRahmen(inhalt, rahmen.appUrl, "Host öffnen und Key prüfen", `/hosts/${h.hostId}`),
|
||||
nutzlast: {
|
||||
ereignis: "hostkey.geaendert",
|
||||
dringend: true,
|
||||
zeitstempel: rahmen.jetzt.toISOString(),
|
||||
instanz: basis(rahmen.appUrl),
|
||||
host: { id: h.hostId, name: h.name, adresse: h.adresse },
|
||||
befunde: h.befunde.map((b) => ({
|
||||
art: "HOSTKEY_GEAENDERT",
|
||||
artText: ART_TEXT["HOSTKEY_GEAENDERT"],
|
||||
algorithmus: b.algorithmus,
|
||||
erwartet: b.erwartet,
|
||||
fingerprint: b.gesehen,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Test
|
||||
|
||||
export function testNachricht(kanal: "EMAIL" | "WEBHOOK", rahmen: Rahmen): Nachricht {
|
||||
const weg = kanal === "EMAIL" ? "E-Mail" : "Webhook";
|
||||
const satz = `Diese Testnachricht bestätigt, dass der Versand per ${weg} funktioniert.`;
|
||||
return {
|
||||
betreff: "[sshkv] Testnachricht",
|
||||
text: `${satz}\nGesendet am ${datumZeit(rahmen.jetzt)}.\n\n${basis(rahmen.appUrl)}/einstellungen`,
|
||||
html: htmlRahmen(
|
||||
`<h2 style="font-size:18px;margin:0 0 8px">Testnachricht</h2><p style="margin:0">${html(satz)}</p><p style="color:#5d6672;margin:8px 0 0">Gesendet am ${html(datumZeit(rahmen.jetzt))}.</p>`,
|
||||
rahmen.appUrl,
|
||||
"Einstellungen öffnen",
|
||||
"/einstellungen",
|
||||
),
|
||||
nutzlast: {
|
||||
ereignis: "test",
|
||||
zeitstempel: rahmen.jetzt.toISOString(),
|
||||
instanz: basis(rahmen.appUrl),
|
||||
text: satz,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Webhook
|
||||
|
||||
/** Der Body wird genau einmal serialisiert – signiert wird, was auch gesendet wird. */
|
||||
export function webhookKoerper(nachricht: Nachricht): string {
|
||||
return JSON.stringify({ betreff: nachricht.betreff, ...nachricht.nutzlast });
|
||||
}
|
||||
|
||||
/** `X-Sshkv-Signatur: sha256=<hex>` – HMAC-SHA256 ueber den rohen Body. */
|
||||
export function signatur(koerper: string, geheimnis: string): string {
|
||||
return `sha256=${createHmac("sha256", geheimnis).update(koerper, "utf8").digest("hex")}`;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type {
|
||||
Befund,
|
||||
HostKeyAenderung,
|
||||
HostUnerreichbar,
|
||||
SchluesselAbgelaufen,
|
||||
SchluesselAblauf,
|
||||
Zusammenfassung,
|
||||
} from "./nachrichten";
|
||||
|
||||
/**
|
||||
* Ein einzelner meldbarer Anlass mit seinem Fingerabdruck (#028). Der Worker sammelt Posten
|
||||
* aus der Datenbank, filtert sie je Kanal gegen das Versandprotokoll und baut aus dem Rest
|
||||
* eine Nachricht – so kann ein Kanal nachholen, was nur bei ihm fehlgeschlagen ist.
|
||||
*/
|
||||
type HostKurz = { readonly hostId: string; readonly name: string; readonly adresse: string };
|
||||
|
||||
export type Posten =
|
||||
| { readonly art: "drift"; readonly anlass: string; readonly host: HostKurz; readonly befund: Befund }
|
||||
| { readonly art: "unerreichbar"; readonly anlass: string; readonly host: HostUnerreichbar }
|
||||
| { readonly art: "ablauf"; readonly anlass: string; readonly schluessel: SchluesselAblauf }
|
||||
| { readonly art: "abgelaufen"; readonly anlass: string; readonly schluessel: SchluesselAbgelaufen }
|
||||
| {
|
||||
readonly art: "hostkey";
|
||||
readonly anlass: string;
|
||||
readonly host: HostKurz;
|
||||
readonly befund: HostKeyAenderung["befunde"][number];
|
||||
};
|
||||
|
||||
/** Fasst die Posten (a)–(d) zusammen; Drift je Host gruppiert, Reihenfolge nach Hostname. */
|
||||
export function zusammenfassungAus(posten: readonly Posten[]): Zusammenfassung {
|
||||
const drift = new Map<string, { host: HostKurz; befunde: Befund[] }>();
|
||||
const unerreichbar: HostUnerreichbar[] = [];
|
||||
const ablauf: SchluesselAblauf[] = [];
|
||||
const abgelaufen: SchluesselAbgelaufen[] = [];
|
||||
for (const p of posten) {
|
||||
if (p.art === "drift") {
|
||||
const eintrag = drift.get(p.host.hostId) ?? { host: p.host, befunde: [] };
|
||||
eintrag.befunde.push(p.befund);
|
||||
drift.set(p.host.hostId, eintrag);
|
||||
} else if (p.art === "unerreichbar") unerreichbar.push(p.host);
|
||||
else if (p.art === "ablauf") ablauf.push(p.schluessel);
|
||||
else if (p.art === "abgelaufen") abgelaufen.push(p.schluessel);
|
||||
}
|
||||
const nachName = (a: { name: string }, b: { name: string }) => a.name.localeCompare(b.name, "de");
|
||||
return {
|
||||
drift: [...drift.values()].map((d) => ({ ...d.host, befunde: d.befunde })).sort(nachName),
|
||||
unerreichbar: unerreichbar.sort(nachName),
|
||||
ablauf: ablauf.sort((a, b) => a.ablaufAm.getTime() - b.ablaufAm.getTime()),
|
||||
abgelaufen: abgelaufen.sort(nachName),
|
||||
};
|
||||
}
|
||||
|
||||
/** Host-Key-Posten (e) je Host – jeder Host bekommt seine eigene dringende Nachricht. */
|
||||
export function hostKeyGruppen(
|
||||
posten: readonly Posten[],
|
||||
): { aenderung: HostKeyAenderung; anlaesse: string[] }[] {
|
||||
const gruppen = new Map<
|
||||
string,
|
||||
{ aenderung: HostKeyAenderung & { befunde: HostKeyAenderung["befunde"][number][] }; anlaesse: string[] }
|
||||
>();
|
||||
for (const p of posten) {
|
||||
if (p.art !== "hostkey") continue;
|
||||
const gruppe = gruppen.get(p.host.hostId) ?? { aenderung: { ...p.host, befunde: [] }, anlaesse: [] };
|
||||
gruppe.aenderung.befunde.push(p.befund);
|
||||
gruppe.anlaesse.push(p.anlass);
|
||||
gruppen.set(p.host.hostId, gruppe);
|
||||
}
|
||||
return [...gruppen.values()];
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { tageBis } from "@/lib/format";
|
||||
|
||||
/**
|
||||
* Regeln der Benachrichtigungen (#028), rein und ohne Datenbank – damit Stufen,
|
||||
* Fingerabdruecke und Wiederholungen als Unit-Test festgehalten werden koennen.
|
||||
*/
|
||||
|
||||
/** Die fuenf Anlaesse; je Anlass in den Einstellungen ein- und ausschaltbar. */
|
||||
export const ANLAESSE = ["drift", "ablauf", "abgelaufen", "unerreichbar", "hostkey"] as const;
|
||||
export type AnlassArt = (typeof ANLAESSE)[number];
|
||||
|
||||
export const ANLASS_TEXT: Readonly<Record<AnlassArt, { titel: string; erklaerung: string }>> = {
|
||||
drift: {
|
||||
titel: "Neue Abweichungen (Drift)",
|
||||
erklaerung: "Nach dem Abgleich neu gefundene Befunde, je Host zusammengefasst.",
|
||||
},
|
||||
ablauf: {
|
||||
titel: "Ablaufende Schlüssel",
|
||||
erklaerung: "30, 14 und 7 Tage vor dem Ablauf – je Stufe genau eine Meldung.",
|
||||
},
|
||||
abgelaufen: {
|
||||
titel: "Abgelaufene, noch ausgerollte Schlüssel",
|
||||
erklaerung: "Abgelaufen, aber noch auf mindestens einem Host hinterlegt.",
|
||||
},
|
||||
unerreichbar: {
|
||||
titel: "Host nicht erreichbar",
|
||||
erklaerung: "Einmal je Ausfall, bis wieder ein Abgleich gelingt.",
|
||||
},
|
||||
hostkey: {
|
||||
titel: "Geänderter Host-Key",
|
||||
erklaerung: "Dringend: wird sofort gemeldet, nicht erst in der Zusammenfassung.",
|
||||
},
|
||||
};
|
||||
|
||||
/** Ablaufstufen in Tagen, absteigend. */
|
||||
export const ABLAUF_STUFEN = [30, 14, 7] as const;
|
||||
export type AblaufStufe = (typeof ABLAUF_STUFEN)[number];
|
||||
|
||||
/**
|
||||
* Die Stufe, in der ein Schluessel gerade steht: die kleinste Stufe, deren Frist schon
|
||||
* erreicht ist. Wer erst mit 10 Tagen Restlaufzeit angelegt wird, bekommt also die
|
||||
* 14-Tage-Meldung und spaeter die 7-Tage-Meldung, aber nie nachtraeglich die 30-Tage-Meldung.
|
||||
* Gezaehlt wird in Kalendertagen (Europe/Berlin) wie ueberall in der Oberflaeche.
|
||||
*/
|
||||
export function ablaufStufe(ablaufAm: Date, jetzt: Date = new Date()): AblaufStufe | null {
|
||||
if (ablaufAm.getTime() <= jetzt.getTime()) return null;
|
||||
const tage = tageBis(ablaufAm, jetzt);
|
||||
for (const stufe of [...ABLAUF_STUFEN].reverse()) {
|
||||
if (tage <= stufe) return stufe;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tag(wert: Date): string {
|
||||
return wert.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fingerabdruecke der Anlaesse – Schluessel des Versandprotokolls. Was hier gleich ist, wird
|
||||
* nur einmal gemeldet. Beim Ablauf steht das Ablaufdatum mit drin: wird ein Schluessel
|
||||
* verlaengert, beginnen die Stufen fuer das neue Datum von vorn.
|
||||
*/
|
||||
export const fingerabdruck = {
|
||||
drift: (abweichungId: string) => `drift:${abweichungId}`,
|
||||
hostkey: (abweichungId: string) => `hostkey:${abweichungId}`,
|
||||
ablauf: (schluesselId: string, stufe: AblaufStufe, ablaufAm: Date) =>
|
||||
`ablauf:${schluesselId}:${stufe}:${tag(ablaufAm)}`,
|
||||
abgelaufen: (schluesselId: string, ablaufAm: Date) => `abgelaufen:${schluesselId}:${tag(ablaufAm)}`,
|
||||
// `letzterAbgleichAm` aendert sich nur bei einem gelungenen Abgleich – so entsteht genau eine
|
||||
// Meldung je Ausfall, und nach der Erholung kann der naechste Ausfall wieder gemeldet werden.
|
||||
unerreichbar: (hostId: string, letzterAbgleichAm: Date | null) =>
|
||||
`unerreichbar:${hostId}:${letzterAbgleichAm ? letzterAbgleichAm.toISOString() : "nie"}`,
|
||||
test: (sendung: string) => `test:${sendung}`,
|
||||
} as const;
|
||||
|
||||
// ---------------------------------------------------------------- Wiederholungen
|
||||
|
||||
/** Nach so vielen Fehlversuchen gibt sshkv eine Zustellung auf. */
|
||||
export const MAX_VERSUCHE = 5;
|
||||
|
||||
/** 5, 10, 20, 40 Minuten – kurz genug fuer einen kurzen Ausfall des Mailservers. */
|
||||
export function naechsterVersuch(versuche: number, jetzt: Date = new Date()): Date | null {
|
||||
if (versuche >= MAX_VERSUCHE) return null;
|
||||
return new Date(jetzt.getTime() + 5 * 60_000 * 2 ** (versuche - 1));
|
||||
}
|
||||
|
||||
export type ProtokollStand = {
|
||||
readonly ergebnis: "GESENDET" | "FEHLGESCHLAGEN";
|
||||
readonly versuche: number;
|
||||
readonly naechsterVersuchAm: Date | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Darf ein Anlass ueber einen Kanal (erneut) gemeldet werden?
|
||||
* - noch nie versucht: ja, ausser es laeuft nur eine Wiederholungsrunde;
|
||||
* - einmal erfolgreich: nie wieder;
|
||||
* - fehlgeschlagen: wenn die Wartezeit um ist und noch Versuche uebrig sind.
|
||||
*/
|
||||
export function anlassFaellig(
|
||||
stand: ProtokollStand | undefined,
|
||||
jetzt: Date = new Date(),
|
||||
nurWiederholungen = false,
|
||||
): boolean {
|
||||
if (!stand) return !nurWiederholungen;
|
||||
if (stand.ergebnis === "GESENDET") return false;
|
||||
if (stand.versuche >= MAX_VERSUCHE || !stand.naechsterVersuchAm) return false;
|
||||
return stand.naechsterVersuchAm.getTime() <= jetzt.getTime();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Webhook-Ziel
|
||||
|
||||
/**
|
||||
* Nur https, ausser `http` ist ausdruecklich erlaubt (internes Ziel). Liefert eine Meldung
|
||||
* fuer das Formular oder null.
|
||||
*/
|
||||
export function webhookUrlPruefen(url: string, httpErlaubt: boolean): string | null {
|
||||
let ziel: URL;
|
||||
try {
|
||||
ziel = new URL(url);
|
||||
} catch {
|
||||
return "Keine gültige Adresse.";
|
||||
}
|
||||
if (ziel.username || ziel.password) {
|
||||
return "Zugangsdaten gehören nicht in die Adresse – bitte das Geheimnis-Feld nutzen.";
|
||||
}
|
||||
if (ziel.protocol === "https:") return null;
|
||||
if (ziel.protocol === "http:") {
|
||||
return httpErlaubt ? null : "Nur https:// – http:// nur mit dem Häkchen für interne Ziele.";
|
||||
}
|
||||
return "Nur https:// (oder ausdrücklich erlaubtes http://).";
|
||||
}
|
||||
|
||||
/** Fuer Protokoll und Versandliste: nur Schema und Host, nie Pfad oder Query (Tokens). */
|
||||
export function webhookZielKurz(url: string): string {
|
||||
try {
|
||||
const ziel = new URL(url);
|
||||
return `${ziel.protocol}//${ziel.host}`;
|
||||
} catch {
|
||||
return "(ungültige Adresse)";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Zeitplaene
|
||||
|
||||
/**
|
||||
* Lesbare Fassung der gaengigen cron-Formen fuer die Einstellungsseite; bei allem anderen
|
||||
* null – dann zeigt die Seite nur den Ausdruck.
|
||||
*/
|
||||
export function cronBeschreiben(ausdruck: string): string | null {
|
||||
const teile = ausdruck.trim().split(/\s+/);
|
||||
if (teile.length !== 5) return null;
|
||||
const [minute, stunde, tagImMonat, monat, wochentag] = teile as [string, string, string, string, string];
|
||||
const zahl = (wert: string, max: number) => /^\d{1,2}$/.test(wert) && Number(wert) <= max;
|
||||
const intervall = /^\*\/(\d{1,2})$/.exec(minute);
|
||||
if (intervall && stunde === "*" && tagImMonat === "*" && monat === "*" && wochentag === "*") {
|
||||
return `alle ${Number(intervall[1])} Minuten`;
|
||||
}
|
||||
if (!zahl(minute, 59) || !zahl(stunde, 23) || tagImMonat !== "*" || monat !== "*") return null;
|
||||
const uhr = `${stunde.padStart(2, "0")}:${minute.padStart(2, "0")} Uhr`;
|
||||
if (wochentag === "*") return `täglich um ${uhr}`;
|
||||
if (wochentag === "1-5") return `werktags um ${uhr}`;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { empfaengerAus, type BenachrichtigungEingabe } from "@/lib/benachrichtigung/einstellungen";
|
||||
import { ANLAESSE, type AnlassArt } from "@/lib/benachrichtigung/regeln";
|
||||
import {
|
||||
akteurVon,
|
||||
fehlerZustand,
|
||||
listenfeld,
|
||||
textfeld,
|
||||
type FormularZustand,
|
||||
} from "@/server/actions/formular";
|
||||
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { benachrichtigungSpeichern, testAnfordern } from "@/server/services/benachrichtigung";
|
||||
import { FachFehler } from "@/server/services/fachfehler";
|
||||
|
||||
/** Einstellungen speichern und Testnachrichten anstossen (#028). Nur `einstellungenVerwalten`. */
|
||||
|
||||
function felder(formular: FormData): BenachrichtigungEingabe {
|
||||
const ja = (name: string) => formular.get(name) === "ja";
|
||||
return {
|
||||
emailAktiv: ja("emailAktiv"),
|
||||
emailServer: textfeld(formular, "emailServer") ?? "",
|
||||
emailPort: textfeld(formular, "emailPort") ?? "",
|
||||
emailSicherheit: (textfeld(formular, "emailSicherheit") ??
|
||||
"STARTTLS") as BenachrichtigungEingabe["emailSicherheit"],
|
||||
emailAbsender: textfeld(formular, "emailAbsender") ?? "",
|
||||
emailBenutzer: textfeld(formular, "emailBenutzer") ?? "",
|
||||
emailPasswort: textfeld(formular, "emailPasswort"),
|
||||
emailPasswortEntfernen: ja("emailPasswortEntfernen"),
|
||||
emailEmpfaenger: empfaengerAus(textfeld(formular, "emailEmpfaenger") ?? ""),
|
||||
webhookAktiv: ja("webhookAktiv"),
|
||||
webhookUrl: textfeld(formular, "webhookUrl") ?? "",
|
||||
webhookHttpErlaubt: ja("webhookHttpErlaubt"),
|
||||
webhookGeheimnis: textfeld(formular, "webhookGeheimnis"),
|
||||
webhookGeheimnisEntfernen: ja("webhookGeheimnisEntfernen"),
|
||||
anlaesse: listenfeld(formular, "anlaesse").filter((a): a is AnlassArt =>
|
||||
(ANLAESSE as readonly string[]).includes(a),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function benachrichtigungSpeichernAktion(
|
||||
_vorher: FormularZustand,
|
||||
formular: FormData,
|
||||
): Promise<FormularZustand & { gespeichert?: boolean }> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("einstellungenVerwalten");
|
||||
await benachrichtigungSpeichern(felder(formular), akteurVon(benutzer));
|
||||
} catch (fehler) {
|
||||
return fehlerZustand(fehler);
|
||||
}
|
||||
revalidatePath("/einstellungen");
|
||||
return { gespeichert: true };
|
||||
}
|
||||
|
||||
export async function testSendenAktion(kanal: string): Promise<{ vorgangId?: string; fehler?: string }> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("einstellungenVerwalten");
|
||||
if (kanal !== "EMAIL" && kanal !== "WEBHOOK") return { fehler: "Unbekannter Kanal." };
|
||||
return { vorgangId: await testAnfordern(kanal, benutzer.id) };
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof RechteFehler || fehler instanceof FachFehler) return { fehler: fehler.message };
|
||||
throw fehler;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { alsAnsicht, type BenachrichtigungAnsicht } from "@/lib/benachrichtigung/einstellungen";
|
||||
import { cronBeschreiben, webhookZielKurz } from "@/lib/benachrichtigung/regeln";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { VERSION } from "@/lib/version";
|
||||
import { oidcAktiv, oidcName } from "@/server/auth/oidc";
|
||||
import { benachrichtigungLesen } from "@/server/services/benachrichtigung";
|
||||
|
||||
/**
|
||||
* Lesemodell der Einstellungsseite (#028). Lesen duerfen alle Rollen; Geheimnisse verlassen
|
||||
* diese Datei nur als „gesetzt ja/nein“ (`alsAnsicht`).
|
||||
*/
|
||||
|
||||
export type ZustellungZeile = {
|
||||
readonly sendung: string;
|
||||
readonly zeitpunkt: Date;
|
||||
readonly kanal: "EMAIL" | "WEBHOOK";
|
||||
readonly betreff: string;
|
||||
readonly anlaesse: number;
|
||||
readonly ergebnis: "GESENDET" | "FEHLGESCHLAGEN";
|
||||
readonly versuche: number;
|
||||
readonly fehler: string | null;
|
||||
readonly naechsterVersuchAm: Date | null;
|
||||
};
|
||||
|
||||
export type Zeitplan = { readonly ausdruck: string; readonly beschreibung: string | null };
|
||||
|
||||
export type EinstellungenSeite = {
|
||||
readonly benachrichtigung: BenachrichtigungAnsicht;
|
||||
readonly webhookZiel: string | null;
|
||||
readonly zustellungen: readonly ZustellungZeile[];
|
||||
readonly abgleich: Zeitplan;
|
||||
readonly meldung: Zeitplan;
|
||||
readonly instanz: {
|
||||
readonly version: string;
|
||||
readonly deploymentKey: { readonly name: string; readonly fingerprint: string } | null;
|
||||
readonly oidc: { readonly name: string; readonly konfiguriert: boolean };
|
||||
readonly notfall: { readonly vorhanden: boolean; readonly totpEingerichtet: boolean };
|
||||
};
|
||||
};
|
||||
|
||||
function zeitplan(variable: string, standard: string): Zeitplan {
|
||||
const ausdruck = process.env[variable]?.trim() || standard;
|
||||
return { ausdruck, beschreibung: cronBeschreiben(ausdruck) };
|
||||
}
|
||||
|
||||
/** Je Nachricht eine Zeile: alle Anlaesse einer Sendung teilen Kennung, Betreff und Ergebnis. */
|
||||
export async function letzteZustellungen(anzahl = 12): Promise<ZustellungZeile[]> {
|
||||
const zeilen = await prisma.zustellung.findMany({
|
||||
orderBy: { zeitpunkt: "desc" },
|
||||
take: 500,
|
||||
select: {
|
||||
sendung: true,
|
||||
zeitpunkt: true,
|
||||
kanal: true,
|
||||
betreff: true,
|
||||
ergebnis: true,
|
||||
versuche: true,
|
||||
fehler: true,
|
||||
naechsterVersuchAm: true,
|
||||
},
|
||||
});
|
||||
const gruppen = new Map<string, ZustellungZeile>();
|
||||
for (const z of zeilen) {
|
||||
const vorhanden = gruppen.get(z.sendung);
|
||||
if (vorhanden) {
|
||||
gruppen.set(z.sendung, {
|
||||
...vorhanden,
|
||||
anlaesse: vorhanden.anlaesse + 1,
|
||||
versuche: Math.max(vorhanden.versuche, z.versuche),
|
||||
});
|
||||
} else {
|
||||
if (gruppen.size >= anzahl) continue;
|
||||
gruppen.set(z.sendung, { ...z, anlaesse: 1 });
|
||||
}
|
||||
}
|
||||
return [...gruppen.values()];
|
||||
}
|
||||
|
||||
export async function einstellungenSeite(): Promise<EinstellungenSeite> {
|
||||
const [gespeichert, zustellungen, deploymentKey, notfall] = await Promise.all([
|
||||
benachrichtigungLesen(),
|
||||
letzteZustellungen(),
|
||||
prisma.schluessel.findFirst({
|
||||
where: { instanzStandard: true },
|
||||
select: { name: true, fingerprint: true },
|
||||
}),
|
||||
prisma.benutzer.findFirst({ where: { istNotfall: true }, select: { totpEingerichtetAm: true } }),
|
||||
]);
|
||||
return {
|
||||
benachrichtigung: alsAnsicht(gespeichert),
|
||||
webhookZiel: gespeichert.webhook.url ? webhookZielKurz(gespeichert.webhook.url) : null,
|
||||
zustellungen,
|
||||
abgleich: zeitplan("SSHKV_SYNC_CRON", "30 2 * * *"),
|
||||
meldung: zeitplan("SSHKV_MELDUNG_CRON", "0 7 * * *"),
|
||||
instanz: {
|
||||
version: VERSION,
|
||||
deploymentKey,
|
||||
oidc: { name: oidcName(), konfiguriert: oidcAktiv() },
|
||||
notfall: { vorhanden: notfall !== null, totpEingerichtet: Boolean(notfall?.totpEingerichtetAm) },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import {
|
||||
benachrichtigungSchema,
|
||||
EINSTELLUNG_BENACHRICHTIGUNG,
|
||||
gespeichertLesen,
|
||||
type BenachrichtigungEingabe,
|
||||
type GespeicherteBenachrichtigung,
|
||||
} from "@/lib/benachrichtigung/einstellungen";
|
||||
import { ANLAESSE, type AnlassArt } from "@/lib/benachrichtigung/regeln";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { verschluesseln } from "@/lib/geheimnisse";
|
||||
import { feldfehlerAus } from "@/lib/hosts";
|
||||
import { FachFehler } from "@/server/services/fachfehler";
|
||||
import type { Akteur } from "@/server/services/hosts";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||
|
||||
/**
|
||||
* Benachrichtigungs-Einstellungen lesen und speichern (#028). Geheimnisse werden hier
|
||||
* verschluesselt und verlassen diese Datei nie im Klartext – entschluesselt wird nur beim
|
||||
* Versand im Worker.
|
||||
*/
|
||||
|
||||
/** Gespeicherter Stand inkl. Chiffraten – nur fuer Service und Worker, nie fuer die Oberflaeche. */
|
||||
export async function benachrichtigungLesen(): Promise<GespeicherteBenachrichtigung> {
|
||||
const eintrag = await prisma.einstellung.findUnique({
|
||||
where: { schluessel: EINSTELLUNG_BENACHRICHTIGUNG },
|
||||
});
|
||||
return gespeichertLesen(eintrag?.wert);
|
||||
}
|
||||
|
||||
export async function benachrichtigungSpeichern(
|
||||
eingabe: BenachrichtigungEingabe,
|
||||
akteur: Akteur,
|
||||
): Promise<void> {
|
||||
const geprueft = benachrichtigungSchema.safeParse(eingabe);
|
||||
if (!geprueft.success) {
|
||||
throw new FachFehler("Bitte die markierten Felder prüfen.", feldfehlerAus(geprueft.error));
|
||||
}
|
||||
const e = geprueft.data;
|
||||
const vorher = await benachrichtigungLesen();
|
||||
|
||||
// Leeres Feld heisst „unveraendert“ – die Oberflaeche kennt das alte Geheimnis ja nicht.
|
||||
const passwortNeu = e.emailPasswort ? e.emailPasswort : undefined;
|
||||
const geheimnisNeu = e.webhookGeheimnis ? e.webhookGeheimnis : undefined;
|
||||
const passwort = e.emailPasswortEntfernen
|
||||
? null
|
||||
: passwortNeu !== undefined
|
||||
? verschluesseln(passwortNeu)
|
||||
: vorher.email.passwort;
|
||||
const geheimnis = e.webhookGeheimnisEntfernen
|
||||
? null
|
||||
: geheimnisNeu !== undefined
|
||||
? verschluesseln(geheimnisNeu)
|
||||
: vorher.webhook.geheimnis;
|
||||
|
||||
const anlaesse = Object.fromEntries(ANLAESSE.map((a) => [a, e.anlaesse.includes(a)])) as Record<
|
||||
AnlassArt,
|
||||
boolean
|
||||
>;
|
||||
const neu: GespeicherteBenachrichtigung = {
|
||||
email: {
|
||||
aktiv: e.emailAktiv,
|
||||
server: e.emailServer,
|
||||
port: e.emailPort,
|
||||
sicherheit: e.emailSicherheit,
|
||||
absender: e.emailAbsender,
|
||||
benutzer: e.emailBenutzer,
|
||||
passwort,
|
||||
empfaenger: e.emailEmpfaenger,
|
||||
},
|
||||
webhook: { aktiv: e.webhookAktiv, url: e.webhookUrl, httpErlaubt: e.webhookHttpErlaubt, geheimnis },
|
||||
anlaesse,
|
||||
};
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.einstellung.upsert({
|
||||
where: { schluessel: EINSTELLUNG_BENACHRICHTIGUNG },
|
||||
create: { schluessel: EINSTELLUNG_BENACHRICHTIGUNG, wert: neu as unknown as Prisma.InputJsonValue },
|
||||
update: { wert: neu as unknown as Prisma.InputJsonValue },
|
||||
});
|
||||
await protokollieren(
|
||||
{
|
||||
akteur,
|
||||
aktion: "einstellungen.benachrichtigungen.geaendert",
|
||||
ergebnis: "ERFOLG",
|
||||
// Nur, ob sich ein Geheimnis geaendert hat – nie der Wert, auch nicht verschluesselt. Die
|
||||
// Namen meiden „passwort“/„geheimnis“, sonst schwaerzt schwaerzen() schon den Wahrheitswert.
|
||||
details: {
|
||||
emailAktiv: neu.email.aktiv,
|
||||
smtpServer: neu.email.server,
|
||||
empfaenger: neu.email.empfaenger.length,
|
||||
webhookAktiv: neu.webhook.aktiv,
|
||||
webhookHttp: neu.webhook.httpErlaubt,
|
||||
smtpZugangGeaendert: passwort !== vorher.email.passwort,
|
||||
signaturSchluesselGeaendert: geheimnis !== vorher.webhook.geheimnis,
|
||||
anlaesse: ANLAESSE.filter((a) => anlaesse[a]),
|
||||
},
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Ist ein Kanal so weit eingetragen, dass ein Versand ueberhaupt Sinn hat? */
|
||||
export function kanalVollstaendig(e: GespeicherteBenachrichtigung, kanal: "EMAIL" | "WEBHOOK"): boolean {
|
||||
if (kanal === "EMAIL") return Boolean(e.email.server && e.email.absender && e.email.empfaenger.length > 0);
|
||||
return Boolean(e.webhook.url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Testnachricht als Vorgang im Worker (#028): dort liegt auch der regulaere Versand, und der
|
||||
* Webprozess braucht so keinen Weg zu Mailserver oder Webhook-Ziel. Getestet wird der
|
||||
* gespeicherte Stand – auch wenn der Kanal noch ausgeschaltet ist, damit man vor dem
|
||||
* Einschalten pruefen kann.
|
||||
*/
|
||||
export async function testAnfordern(kanal: "EMAIL" | "WEBHOOK", benutzerId: string): Promise<string> {
|
||||
const einstellungen = await benachrichtigungLesen();
|
||||
if (!kanalVollstaendig(einstellungen, kanal)) {
|
||||
throw new FachFehler(
|
||||
kanal === "EMAIL"
|
||||
? "Erst Server, Absender und Empfänger speichern."
|
||||
: "Erst eine Webhook-Adresse speichern.",
|
||||
);
|
||||
}
|
||||
return vorgangEinreihen({ art: "BENACHRICHTIGUNG_TEST", angefordertVonId: benutzerId, eingabe: { kanal } });
|
||||
}
|
||||
@@ -10,6 +10,9 @@ import { PgBoss } from "pg-boss";
|
||||
/** Eine Queue je Vorgangsart haelt Wiederholungen und Gleichzeitigkeit getrennt einstellbar. */
|
||||
export const WARTESCHLANGE = "sshkv-vorgang";
|
||||
export const ZEITPLAN_ABGLEICH = "sshkv-abgleich-zeitplan";
|
||||
/** Taegliche Zusammenfassung und Fuenf-Minuten-Takt der Benachrichtigungen (#028). */
|
||||
export const ZEITPLAN_MELDUNG = "sshkv-meldung-zeitplan";
|
||||
export const ZEITPLAN_MELDUNG_TAKT = "sshkv-meldung-takt";
|
||||
|
||||
/** Was im Job steht. Absichtlich nur die ID: alles Weitere liegt im `Vorgang`. */
|
||||
export type VorgangsJob = {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { VorgangsFehler, type Handler } from "@/worker/ausfuehren";
|
||||
import { testVersenden } from "@/worker/benachrichtigung/versand";
|
||||
|
||||
/**
|
||||
* Vorgang BENACHRICHTIGUNG_TEST (#028): Testnachricht ueber einen Kanal. Ein Fehlschlag laesst
|
||||
* den Vorgang scheitern – die Oberflaeche zeigt dann den bereinigten Fehlertext.
|
||||
*/
|
||||
export const benachrichtigungTestHandler: Handler = async ({ vorgang }) => {
|
||||
const kanal = (vorgang.eingabe as { kanal?: unknown } | null)?.kanal;
|
||||
if (kanal !== "EMAIL" && kanal !== "WEBHOOK") throw new VorgangsFehler("Unbekannter Kanal.");
|
||||
const benutzer = vorgang.angefordertVonId
|
||||
? await prisma.benutzer.findUnique({
|
||||
where: { id: vorgang.angefordertVonId },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
: null;
|
||||
const fehler = await testVersenden(kanal, benutzer ?? undefined);
|
||||
if (fehler) throw new VorgangsFehler(fehler);
|
||||
return { ergebnis: { kanal, gesendet: true } };
|
||||
};
|
||||
@@ -0,0 +1,182 @@
|
||||
import type { Posten } from "@/lib/benachrichtigung/posten";
|
||||
import { ablaufStufe, fingerabdruck, type AnlassArt } from "@/lib/benachrichtigung/regeln";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { tageBis } from "@/lib/format";
|
||||
|
||||
/**
|
||||
* Sammelt die meldbaren Anlaesse aus der Datenbank (#028). Der Abgleich selbst meldet nichts:
|
||||
* er schreibt Abweichungen und Erreichbarkeit, und diese Datei liest sie. So bleibt der
|
||||
* Abgleich-Ablauf unberuehrt, und ein verpasster Versand holt sich der naechste Lauf.
|
||||
*/
|
||||
|
||||
const AUSGEROLLT = ["AUSGEROLLT", "ABWEICHUNG"] as const;
|
||||
|
||||
async function drift(): Promise<Posten[]> {
|
||||
const offen = await prisma.abweichung.findMany({
|
||||
where: { behobenAm: null, art: { not: "HOSTKEY_GEAENDERT" }, host: { archiviertAm: null } },
|
||||
select: {
|
||||
id: true,
|
||||
art: true,
|
||||
zielbenutzer: true,
|
||||
fingerprint: true,
|
||||
details: true,
|
||||
host: { select: { id: true, name: true, adresse: true } },
|
||||
},
|
||||
orderBy: { eroeffnetAm: "asc" },
|
||||
});
|
||||
return offen.map((a) => ({
|
||||
art: "drift",
|
||||
anlass: fingerabdruck.drift(a.id),
|
||||
host: { hostId: a.host.id, name: a.host.name, adresse: a.host.adresse },
|
||||
befund: {
|
||||
art: a.art,
|
||||
zielbenutzer: a.zielbenutzer,
|
||||
fingerprint: a.fingerprint,
|
||||
text: ((a.details ?? {}) as { text?: unknown }).text?.toString() ?? null,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async function hostKey(): Promise<Posten[]> {
|
||||
const offen = await prisma.abweichung.findMany({
|
||||
where: { behobenAm: null, art: "HOSTKEY_GEAENDERT", host: { archiviertAm: null } },
|
||||
select: {
|
||||
id: true,
|
||||
fingerprint: true,
|
||||
details: true,
|
||||
host: { select: { id: true, name: true, adresse: true } },
|
||||
},
|
||||
orderBy: { eroeffnetAm: "asc" },
|
||||
});
|
||||
return offen.map((a) => {
|
||||
const details = (a.details ?? {}) as { erwartet?: unknown; algorithmus?: unknown };
|
||||
return {
|
||||
art: "hostkey",
|
||||
anlass: fingerabdruck.hostkey(a.id),
|
||||
host: { hostId: a.host.id, name: a.host.name, adresse: a.host.adresse },
|
||||
befund: {
|
||||
algorithmus: typeof details.algorithmus === "string" ? details.algorithmus : "unbekannt",
|
||||
erwartet: typeof details.erwartet === "string" ? details.erwartet : null,
|
||||
gesehen: a.fingerprint ?? "unbekannt",
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function unerreichbar(): Promise<Posten[]> {
|
||||
const hosts = await prisma.host.findMany({
|
||||
where: {
|
||||
archiviertAm: null,
|
||||
bootstrapAm: { not: null },
|
||||
erreichbar: false,
|
||||
// Ein geaenderter Host-Key laesst den Abgleich ebenfalls scheitern; dafuer gibt es die
|
||||
// dringende Meldung (e) – hier waere sie nur ein irrefuehrendes Echo.
|
||||
abweichungen: { none: { art: "HOSTKEY_GEAENDERT", behobenAm: null } },
|
||||
},
|
||||
select: { id: true, name: true, adresse: true, zuletztGeprueftAm: true, letzterAbgleichAm: true },
|
||||
});
|
||||
return hosts.map((h) => ({
|
||||
art: "unerreichbar",
|
||||
anlass: fingerabdruck.unerreichbar(h.id, h.letzterAbgleichAm),
|
||||
host: {
|
||||
hostId: h.id,
|
||||
name: h.name,
|
||||
adresse: h.adresse,
|
||||
zuletztGeprueftAm: h.zuletztGeprueftAm,
|
||||
letzterAbgleichAm: h.letzterAbgleichAm,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async function ablauf(jetzt: Date): Promise<Posten[]> {
|
||||
const bis = new Date(jetzt.getTime() + 31 * 86_400_000);
|
||||
// Feste select-Liste: Private-Key-Felder kommen hier nie vorbei (CLAUDE.md).
|
||||
const schluessel = await prisma.schluessel.findMany({
|
||||
where: { status: "AKTIV", widerrufEingeleitetAm: null, ablaufAm: { gt: jetzt, lte: bis } },
|
||||
select: { id: true, name: true, fingerprint: true, ablaufAm: true },
|
||||
});
|
||||
const posten: Posten[] = [];
|
||||
for (const s of schluessel) {
|
||||
if (!s.ablaufAm) continue;
|
||||
const stufe = ablaufStufe(s.ablaufAm, jetzt);
|
||||
if (stufe === null) continue;
|
||||
posten.push({
|
||||
art: "ablauf",
|
||||
anlass: fingerabdruck.ablauf(s.id, stufe, s.ablaufAm),
|
||||
schluessel: {
|
||||
schluesselId: s.id,
|
||||
name: s.name,
|
||||
fingerprint: s.fingerprint,
|
||||
ablaufAm: s.ablaufAm,
|
||||
tage: tageBis(s.ablaufAm, jetzt),
|
||||
stufe,
|
||||
},
|
||||
});
|
||||
}
|
||||
return posten;
|
||||
}
|
||||
|
||||
async function abgelaufen(jetzt: Date): Promise<Posten[]> {
|
||||
const schluessel = await prisma.schluessel.findMany({
|
||||
where: {
|
||||
status: { in: ["AKTIV", "ABGELAUFEN"] },
|
||||
ablaufAm: { lte: jetzt },
|
||||
OR: [
|
||||
{ publicVerwendungen: { some: { zustand: { in: [...AUSGEROLLT] } } } },
|
||||
{ privateVerwendungen: { some: { zustand: { in: [...AUSGEROLLT] } } } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
fingerprint: true,
|
||||
ablaufAm: true,
|
||||
publicVerwendungen: {
|
||||
where: { zustand: { in: [...AUSGEROLLT] } },
|
||||
select: { host: { select: { name: true } }, hostgruppe: { select: { name: true } } },
|
||||
},
|
||||
privateVerwendungen: {
|
||||
where: { zustand: { in: [...AUSGEROLLT] } },
|
||||
select: { host: { select: { name: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
return schluessel.flatMap((s): Posten[] => {
|
||||
if (!s.ablaufAm) return [];
|
||||
const hosts = new Set<string>();
|
||||
for (const v of s.publicVerwendungen) {
|
||||
if (v.host) hosts.add(v.host.name);
|
||||
else if (v.hostgruppe) hosts.add(`Gruppe ${v.hostgruppe.name}`);
|
||||
}
|
||||
for (const v of s.privateVerwendungen) hosts.add(v.host.name);
|
||||
return [
|
||||
{
|
||||
art: "abgelaufen",
|
||||
anlass: fingerabdruck.abgelaufen(s.id, s.ablaufAm),
|
||||
schluessel: {
|
||||
schluesselId: s.id,
|
||||
name: s.name,
|
||||
fingerprint: s.fingerprint,
|
||||
ablaufAm: s.ablaufAm,
|
||||
hosts: [...hosts].sort((a, b) => a.localeCompare(b, "de")),
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** Alle Posten der eingeschalteten Anlaesse. */
|
||||
export async function postenSammeln(
|
||||
arten: readonly AnlassArt[],
|
||||
jetzt: Date = new Date(),
|
||||
): Promise<Posten[]> {
|
||||
const sammler: Record<AnlassArt, () => Promise<Posten[]>> = {
|
||||
drift,
|
||||
hostkey: hostKey,
|
||||
unerreichbar,
|
||||
ablauf: () => ablauf(jetzt),
|
||||
abgelaufen: () => abgelaufen(jetzt),
|
||||
};
|
||||
const teile = await Promise.all(arten.map((art) => sammler[art]()));
|
||||
return teile.flat();
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import type { GespeicherteBenachrichtigung, SmtpSicherheit } from "@/lib/benachrichtigung/einstellungen";
|
||||
import { signatur, webhookKoerper, type Nachricht } from "@/lib/benachrichtigung/nachrichten";
|
||||
import { webhookUrlPruefen } from "@/lib/benachrichtigung/regeln";
|
||||
import { entschluesseln } from "@/lib/geheimnisse";
|
||||
import { VERSION } from "@/lib/version";
|
||||
|
||||
/**
|
||||
* Versandwege (#028): SMTP (Microsoft 365 mit STARTTLS auf 587) und Webhook mit HMAC-Signatur.
|
||||
* Uebernommen aus ProxLedger, ergaenzt um Zeitlimits, die Wahl „unverschluesselt“ fuer interne
|
||||
* Relays und Fehlermeldungen ohne Zugangsdaten.
|
||||
*/
|
||||
export class ZustellFehler extends Error {
|
||||
constructor(nachricht: string) {
|
||||
super(nachricht);
|
||||
this.name = "ZustellFehler";
|
||||
}
|
||||
}
|
||||
|
||||
export type EmailZugang = {
|
||||
readonly server: string;
|
||||
readonly port: number;
|
||||
readonly sicherheit: SmtpSicherheit;
|
||||
readonly absender: string;
|
||||
readonly benutzer: string;
|
||||
readonly passwort: string | null;
|
||||
readonly empfaenger: readonly string[];
|
||||
};
|
||||
|
||||
export type WebhookZugang = {
|
||||
readonly url: string;
|
||||
readonly httpErlaubt: boolean;
|
||||
readonly geheimnis: string | null;
|
||||
};
|
||||
|
||||
/** Entschluesselt erst hier, unmittelbar vor dem Versand. */
|
||||
export function zugaengeLesen(e: GespeicherteBenachrichtigung): {
|
||||
email: EmailZugang;
|
||||
webhook: WebhookZugang;
|
||||
} {
|
||||
return {
|
||||
email: { ...e.email, passwort: e.email.passwort ? entschluesseln(e.email.passwort) : null },
|
||||
webhook: {
|
||||
url: e.webhook.url,
|
||||
httpErlaubt: e.webhook.httpErlaubt,
|
||||
geheimnis: e.webhook.geheimnis ? entschluesseln(e.webhook.geheimnis) : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fehlertexte kommen vom Mailserver oder aus dem Netz. Sie landen im Versandprotokoll und in der
|
||||
* Oberflaeche – deshalb gekuerzt und ohne die Zugangsdaten, falls ein Server sie zurueckspiegelt.
|
||||
*/
|
||||
export function fehlerBereinigen(fehler: unknown, geheim: readonly (string | null)[]): string {
|
||||
let text = fehler instanceof Error ? fehler.message : String(fehler);
|
||||
for (const wert of geheim) if (wert) text = text.replaceAll(wert, "***");
|
||||
text = text.replace(/\s+/g, " ").trim();
|
||||
return text.length > 300 ? `${text.slice(0, 297)}…` : text || "Unbekannter Fehler.";
|
||||
}
|
||||
|
||||
export async function perEmailSenden(zugang: EmailZugang, nachricht: Nachricht): Promise<void> {
|
||||
if (zugang.empfaenger.length === 0) throw new ZustellFehler("Kein Empfänger hinterlegt.");
|
||||
const transport = nodemailer.createTransport({
|
||||
host: zugang.server,
|
||||
port: zugang.port,
|
||||
secure: zugang.sicherheit === "TLS",
|
||||
// Microsoft 365 verlangt STARTTLS; requireTLS verhindert, dass ein Server ohne STARTTLS
|
||||
// die Zugangsdaten im Klartext bekommt.
|
||||
requireTLS: zugang.sicherheit === "STARTTLS",
|
||||
ignoreTLS: zugang.sicherheit === "KEINE",
|
||||
auth: zugang.benutzer ? { user: zugang.benutzer, pass: zugang.passwort ?? "" } : undefined,
|
||||
connectionTimeout: 15_000,
|
||||
greetingTimeout: 15_000,
|
||||
socketTimeout: 30_000,
|
||||
});
|
||||
try {
|
||||
await transport.sendMail({
|
||||
from: zugang.absender,
|
||||
to: zugang.empfaenger.join(", "),
|
||||
subject: nachricht.betreff,
|
||||
text: nachricht.text,
|
||||
html: nachricht.html,
|
||||
headers: { "X-Sshkv-Ereignis": nachricht.nutzlast.ereignis },
|
||||
});
|
||||
} catch (fehler) {
|
||||
throw new ZustellFehler(`SMTP: ${fehlerBereinigen(fehler, [zugang.passwort])}`);
|
||||
} finally {
|
||||
transport.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function perWebhookSenden(zugang: WebhookZugang, nachricht: Nachricht): Promise<void> {
|
||||
// Noch einmal hier: die Einstellung koennte an der Oberflaeche vorbei geschrieben worden sein.
|
||||
const ungueltig = webhookUrlPruefen(zugang.url, zugang.httpErlaubt);
|
||||
if (ungueltig) throw new ZustellFehler(`Webhook: ${ungueltig}`);
|
||||
|
||||
const koerper = webhookKoerper(nachricht);
|
||||
const kopf: Record<string, string> = {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"User-Agent": `sshkv/${VERSION}`,
|
||||
"X-Sshkv-Ereignis": nachricht.nutzlast.ereignis,
|
||||
};
|
||||
if (zugang.geheimnis) kopf["X-Sshkv-Signatur"] = signatur(koerper, zugang.geheimnis);
|
||||
|
||||
let antwort: Response;
|
||||
try {
|
||||
antwort = await fetch(zugang.url, {
|
||||
method: "POST",
|
||||
headers: kopf,
|
||||
body: koerper,
|
||||
// Keine Weiterleitungen: sonst koennte ein https-Ziel auf http umleiten.
|
||||
redirect: "error",
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
} catch (fehler) {
|
||||
throw new ZustellFehler(`Webhook nicht erreichbar: ${fehlerBereinigen(fehler, [zugang.geheimnis])}`);
|
||||
}
|
||||
// Die Antwort wird nicht gelesen – sie interessiert nicht und koennte gross sein.
|
||||
await antwort.body?.cancel().catch(() => undefined);
|
||||
if (!antwort.ok) throw new ZustellFehler(`Webhook antwortet mit HTTP ${antwort.status}.`);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { MeldungsKanal } from "@/generated/prisma/enums";
|
||||
import type { GespeicherteBenachrichtigung } from "@/lib/benachrichtigung/einstellungen";
|
||||
import {
|
||||
hostKeyNachricht,
|
||||
testNachricht,
|
||||
zusammenfassungLeer,
|
||||
zusammenfassungNachricht,
|
||||
type Nachricht,
|
||||
} from "@/lib/benachrichtigung/nachrichten";
|
||||
import { hostKeyGruppen, zusammenfassungAus, type Posten } from "@/lib/benachrichtigung/posten";
|
||||
import {
|
||||
anlassFaellig,
|
||||
fingerabdruck,
|
||||
naechsterVersuch,
|
||||
webhookZielKurz,
|
||||
type AnlassArt,
|
||||
type ProtokollStand,
|
||||
} from "@/lib/benachrichtigung/regeln";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { benachrichtigungLesen } from "@/server/services/benachrichtigung";
|
||||
import type { Akteur } from "@/server/services/hosts";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
import { postenSammeln } from "./anlaesse";
|
||||
import { perEmailSenden, perWebhookSenden, zugaengeLesen, ZustellFehler } from "./kanaele";
|
||||
|
||||
/**
|
||||
* Wer bekommt wann was (#028). Grundregel: kein Anlass wird ueber denselben Kanal zweimal
|
||||
* gemeldet. Das sichert die Pruefung gegen das Versandprotokoll (`Zustellung`) vor jedem
|
||||
* Versand – auch wenn ein Zeitplan doppelt feuert oder zwei Laeufe sich ueberschneiden.
|
||||
*/
|
||||
|
||||
export type VersandBilanz = { gesendet: number; fehlgeschlagen: number };
|
||||
|
||||
const ZUSAMMENFASSUNG: readonly AnlassArt[] = ["drift", "ablauf", "abgelaufen", "unerreichbar"];
|
||||
|
||||
function appUrl(): string {
|
||||
return process.env["SSHKV_APP_URL"]?.trim() || "http://localhost:3400";
|
||||
}
|
||||
|
||||
function aktiveKanaele(e: GespeicherteBenachrichtigung): MeldungsKanal[] {
|
||||
const kanaele: MeldungsKanal[] = [];
|
||||
if (e.email.aktiv) kanaele.push("EMAIL");
|
||||
if (e.webhook.aktiv) kanaele.push("WEBHOOK");
|
||||
return kanaele;
|
||||
}
|
||||
|
||||
/** Die Posten, die ueber diesen Kanal (noch einmal) gemeldet werden duerfen. */
|
||||
async function faelligFuer(
|
||||
posten: readonly Posten[],
|
||||
kanal: MeldungsKanal,
|
||||
jetzt: Date,
|
||||
nurWiederholungen: boolean,
|
||||
): Promise<{ posten: Posten[]; stand: Map<string, ProtokollStand> }> {
|
||||
if (posten.length === 0) return { posten: [], stand: new Map() };
|
||||
const zeilen = await prisma.zustellung.findMany({
|
||||
where: { kanal, anlass: { in: posten.map((p) => p.anlass) } },
|
||||
select: { anlass: true, ergebnis: true, versuche: true, naechsterVersuchAm: true },
|
||||
});
|
||||
const stand = new Map(zeilen.map((z) => [z.anlass, z]));
|
||||
return {
|
||||
posten: posten.filter((p) => anlassFaellig(stand.get(p.anlass), jetzt, nurWiederholungen)),
|
||||
stand,
|
||||
};
|
||||
}
|
||||
|
||||
type Versandauftrag = {
|
||||
readonly kanal: MeldungsKanal;
|
||||
readonly einstellungen: GespeicherteBenachrichtigung;
|
||||
readonly nachricht: Nachricht;
|
||||
readonly anlaesse: readonly string[];
|
||||
readonly stand: ReadonlyMap<string, ProtokollStand>;
|
||||
readonly jetzt: Date;
|
||||
readonly akteur?: Akteur;
|
||||
/** Testnachrichten werden nie wiederholt – das Ergebnis sieht der Mensch sofort. */
|
||||
readonly wiederholen?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sendet eine Nachricht ueber einen Kanal und schreibt das Ergebnis je Anlass ins
|
||||
* Versandprotokoll und einmal ins revisionssichere Protokoll. Liefert den Fehlertext oder null.
|
||||
*/
|
||||
async function sendenUndFesthalten(auftrag: Versandauftrag): Promise<string | null> {
|
||||
const { kanal, einstellungen, nachricht, jetzt } = auftrag;
|
||||
let fehler: string | null = null;
|
||||
try {
|
||||
const zugang = zugaengeLesen(einstellungen);
|
||||
if (kanal === "EMAIL") await perEmailSenden(zugang.email, nachricht);
|
||||
else await perWebhookSenden(zugang.webhook, nachricht);
|
||||
} catch (ursache) {
|
||||
// Fremde Fehler (z. B. ein nicht mehr entschluesselbares Passwort) mit fester Meldung –
|
||||
// ihre Texte koennen Interna tragen.
|
||||
fehler =
|
||||
ursache instanceof ZustellFehler
|
||||
? ursache.message
|
||||
: ursache instanceof Error && ursache.name === "GeheimnisFehler"
|
||||
? "Zugangsdaten nicht lesbar – bitte Passwort bzw. Geheimnis neu eingeben."
|
||||
: "Interner Fehler beim Versand. Details stehen im Worker-Log.";
|
||||
if (!(ursache instanceof ZustellFehler)) {
|
||||
console.error(
|
||||
`Benachrichtigung über ${kanal} fehlgeschlagen:`,
|
||||
ursache instanceof Error ? `${ursache.name}: ${ursache.message}` : String(ursache),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const sendung = randomUUID();
|
||||
const wiederholen = auftrag.wiederholen ?? true;
|
||||
for (const anlass of auftrag.anlaesse) {
|
||||
const versuche = (auftrag.stand.get(anlass)?.versuche ?? 0) + 1;
|
||||
const daten = {
|
||||
ergebnis: fehler ? ("FEHLGESCHLAGEN" as const) : ("GESENDET" as const),
|
||||
fehler,
|
||||
versuche,
|
||||
naechsterVersuchAm: fehler && wiederholen ? naechsterVersuch(versuche, jetzt) : null,
|
||||
sendung,
|
||||
betreff: nachricht.betreff,
|
||||
zeitpunkt: jetzt,
|
||||
};
|
||||
await prisma.zustellung.upsert({
|
||||
where: { anlass_kanal: { anlass, kanal } },
|
||||
create: { anlass, kanal, ...daten },
|
||||
update: daten,
|
||||
});
|
||||
}
|
||||
|
||||
await protokollieren({
|
||||
akteur: auftrag.akteur ?? "System",
|
||||
aktion: fehler ? "benachrichtigung.fehlgeschlagen" : "benachrichtigung.gesendet",
|
||||
ergebnis: fehler ? "FEHLER" : "ERFOLG",
|
||||
details: {
|
||||
kanal,
|
||||
ereignis: nachricht.nutzlast.ereignis,
|
||||
betreff: nachricht.betreff,
|
||||
anlaesse: auftrag.anlaesse.length,
|
||||
// Empfaenger nur gezaehlt, beim Webhook nur Schema und Host – der Pfad traegt oft ein Token.
|
||||
ziel:
|
||||
kanal === "EMAIL"
|
||||
? `${einstellungen.email.empfaenger.length} Empfänger`
|
||||
: webhookZielKurz(einstellungen.webhook.url),
|
||||
sendung,
|
||||
...(fehler ? { fehler } : {}),
|
||||
},
|
||||
});
|
||||
return fehler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Laeufe nacheinander: ueberschnitten sich Zusammenfassung und Takt, saehen beide denselben
|
||||
* Anlass als „noch nicht gemeldet“. pg-boss feuert jeden Zeitplan nur einmal, die
|
||||
* Ueberschneidung zweier Zeitplaene im selben Worker verhindert erst diese Kette.
|
||||
*/
|
||||
let kette: Promise<unknown> = Promise.resolve();
|
||||
function nacheinander<T>(lauf: () => Promise<T>): Promise<T> {
|
||||
const ergebnis = kette.then(lauf, lauf);
|
||||
kette = ergebnis.catch(() => undefined);
|
||||
return ergebnis;
|
||||
}
|
||||
|
||||
function zaehlen(bilanz: VersandBilanz, fehler: string | null): void {
|
||||
if (fehler) bilanz.fehlgeschlagen += 1;
|
||||
else bilanz.gesendet += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Taegliche Zusammenfassung der Anlaesse (a)–(d). Mit `nurWiederholungen` (Fuenf-Minuten-Takt)
|
||||
* nur das, was zuvor fehlschlug und wieder an der Reihe ist – Neues wartet auf den Termin.
|
||||
*/
|
||||
export function zusammenfassungVersenden(jetzt: Date = new Date(), nurWiederholungen = false) {
|
||||
return nacheinander(() => zusammenfassungIntern(jetzt, nurWiederholungen));
|
||||
}
|
||||
|
||||
async function zusammenfassungIntern(jetzt: Date, nurWiederholungen: boolean): Promise<VersandBilanz> {
|
||||
const bilanz: VersandBilanz = { gesendet: 0, fehlgeschlagen: 0 };
|
||||
const einstellungen = await benachrichtigungLesen();
|
||||
const kanaele = aktiveKanaele(einstellungen);
|
||||
const arten = ZUSAMMENFASSUNG.filter((a) => einstellungen.anlaesse[a]);
|
||||
if (kanaele.length === 0 || arten.length === 0) return bilanz;
|
||||
|
||||
const alle = await postenSammeln(arten, jetzt);
|
||||
for (const kanal of kanaele) {
|
||||
const { posten, stand } = await faelligFuer(alle, kanal, jetzt, nurWiederholungen);
|
||||
const zusammenfassung = zusammenfassungAus(posten);
|
||||
if (zusammenfassungLeer(zusammenfassung)) continue;
|
||||
const nachricht = zusammenfassungNachricht(zusammenfassung, { appUrl: appUrl(), jetzt });
|
||||
zaehlen(
|
||||
bilanz,
|
||||
await sendenUndFesthalten({
|
||||
kanal,
|
||||
einstellungen,
|
||||
nachricht,
|
||||
anlaesse: posten.map((p) => p.anlass),
|
||||
stand,
|
||||
jetzt,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return bilanz;
|
||||
}
|
||||
|
||||
/** Dringende Meldungen (e): geaenderte Host-Keys, je Host eine Nachricht. */
|
||||
export function hostKeyMeldungenVersenden(jetzt: Date = new Date()) {
|
||||
return nacheinander(() => hostKeyIntern(jetzt));
|
||||
}
|
||||
|
||||
async function hostKeyIntern(jetzt: Date): Promise<VersandBilanz> {
|
||||
const bilanz: VersandBilanz = { gesendet: 0, fehlgeschlagen: 0 };
|
||||
const einstellungen = await benachrichtigungLesen();
|
||||
const kanaele = aktiveKanaele(einstellungen);
|
||||
if (kanaele.length === 0 || !einstellungen.anlaesse.hostkey) return bilanz;
|
||||
|
||||
const alle = await postenSammeln(["hostkey"], jetzt);
|
||||
for (const kanal of kanaele) {
|
||||
const { posten, stand } = await faelligFuer(alle, kanal, jetzt, false);
|
||||
for (const gruppe of hostKeyGruppen(posten)) {
|
||||
const nachricht = hostKeyNachricht(gruppe.aenderung, { appUrl: appUrl(), jetzt });
|
||||
zaehlen(
|
||||
bilanz,
|
||||
await sendenUndFesthalten({
|
||||
kanal,
|
||||
einstellungen,
|
||||
nachricht,
|
||||
anlaesse: gruppe.anlaesse,
|
||||
stand,
|
||||
jetzt,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return bilanz;
|
||||
}
|
||||
|
||||
/** Der Fuenf-Minuten-Takt: dringende Meldungen und faellige Wiederholungen. */
|
||||
export function taktVersenden(jetzt: Date = new Date()): Promise<VersandBilanz> {
|
||||
return nacheinander(async () => {
|
||||
const dringend = await hostKeyIntern(jetzt);
|
||||
const nachgeholt = await zusammenfassungIntern(jetzt, true);
|
||||
return {
|
||||
gesendet: dringend.gesendet + nachgeholt.gesendet,
|
||||
fehlgeschlagen: dringend.fehlgeschlagen + nachgeholt.fehlgeschlagen,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** „Testnachricht senden“ – ueber den gespeicherten Stand, auch bei ausgeschaltetem Kanal. */
|
||||
export async function testVersenden(kanal: MeldungsKanal, akteur?: Akteur): Promise<string | null> {
|
||||
const jetzt = new Date();
|
||||
const einstellungen = await benachrichtigungLesen();
|
||||
const nachricht = testNachricht(kanal, { appUrl: appUrl(), jetzt });
|
||||
return sendenUndFesthalten({
|
||||
kanal,
|
||||
einstellungen,
|
||||
nachricht,
|
||||
anlaesse: [fingerabdruck.test(randomUUID())],
|
||||
stand: new Map(),
|
||||
jetzt,
|
||||
...(akteur ? { akteur } : {}),
|
||||
wiederholen: false,
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { HandlerTabelle } from "@/worker/ausfuehren";
|
||||
import { abgleichHandler } from "@/worker/ablaeufe/abgleich";
|
||||
import { benachrichtigungTestHandler } from "@/worker/ablaeufe/benachrichtigung";
|
||||
import { ausrollenHandler, trockenlaufHandler } from "@/worker/ablaeufe/ausrollen";
|
||||
import { bootstrapHandler } from "@/worker/ablaeufe/bootstrap";
|
||||
import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey";
|
||||
@@ -25,4 +26,5 @@ export const HANDLER: HandlerTabelle = {
|
||||
ABGLEICH: abgleichHandler,
|
||||
ZIELKONTO_ANLEGEN: zielkontoAnlegenHandler,
|
||||
ZIELKONTO_ENTFERNEN: zielkontoEntfernenHandler,
|
||||
BENACHRICHTIGUNG_TEST: benachrichtigungTestHandler,
|
||||
};
|
||||
|
||||
+29
-1
@@ -5,11 +5,14 @@ import { alleHostsAbgleichen } from "@/server/services/abgleich";
|
||||
import {
|
||||
WARTESCHLANGE,
|
||||
ZEITPLAN_ABGLEICH,
|
||||
ZEITPLAN_MELDUNG,
|
||||
ZEITPLAN_MELDUNG_TAKT,
|
||||
warteschlangeHolen,
|
||||
type VorgangsJob,
|
||||
} from "@/server/vorgaenge/warteschlange";
|
||||
import { vorgangAusfuehren } from "@/worker/ausfuehren";
|
||||
import { abholungenAufraeumen } from "@/worker/ablaeufe/privatekey";
|
||||
import { taktVersenden, zusammenfassungVersenden } from "@/worker/benachrichtigung/versand";
|
||||
import { deploymentKeySicherstellen } from "@/worker/geheimnisse";
|
||||
import { HANDLER } from "@/worker/handler";
|
||||
|
||||
@@ -61,7 +64,32 @@ async function starten(): Promise<void> {
|
||||
console.log(`Zeitplan: ${anzahl} Abgleich(e) eingereiht.`);
|
||||
});
|
||||
|
||||
console.log(`sshkv-worker ${VERSION} bereit (Abgleich: ${zeitplan}).`);
|
||||
// Benachrichtigungen (#028): taeglich die Zusammenfassung, alle fuenf Minuten geaenderte
|
||||
// Host-Keys (dringend) und faellige Wiederholungen. Fehler nur ins Log – das
|
||||
// Versandprotokoll haelt fest, was nachzuholen ist.
|
||||
const meldung = process.env["SSHKV_MELDUNG_CRON"]?.trim() || "0 7 * * *";
|
||||
const melden = (lauf: () => Promise<{ gesendet: number; fehlgeschlagen: number }>) => async () => {
|
||||
const bilanz = await lauf().catch((fehler: unknown) => {
|
||||
console.error("Benachrichtigung:", fehler instanceof Error ? fehler.message : fehler);
|
||||
return null;
|
||||
});
|
||||
if (bilanz && bilanz.gesendet + bilanz.fehlgeschlagen > 0)
|
||||
console.log(`Benachrichtigung: ${bilanz.gesendet} gesendet, ${bilanz.fehlgeschlagen} fehlgeschlagen.`);
|
||||
};
|
||||
await boss.createQueue(ZEITPLAN_MELDUNG);
|
||||
await boss.schedule(ZEITPLAN_MELDUNG, meldung, null, { tz: "Europe/Berlin" });
|
||||
await boss.work(
|
||||
ZEITPLAN_MELDUNG,
|
||||
melden(() => zusammenfassungVersenden()),
|
||||
);
|
||||
await boss.createQueue(ZEITPLAN_MELDUNG_TAKT);
|
||||
await boss.schedule(ZEITPLAN_MELDUNG_TAKT, "*/5 * * * *", null, { tz: "Europe/Berlin" });
|
||||
await boss.work(
|
||||
ZEITPLAN_MELDUNG_TAKT,
|
||||
melden(() => taktVersenden()),
|
||||
);
|
||||
|
||||
console.log(`sshkv-worker ${VERSION} bereit (Abgleich: ${zeitplan}, Meldungen: ${meldung}).`);
|
||||
|
||||
const beenden = async (signal: string) => {
|
||||
console.log(`${signal} empfangen, beende laufende Vorgänge …`);
|
||||
|
||||
Reference in New Issue
Block a user