Bisher ließ sich der Instanz-Key nur von Hand auf jedem Host tauschen. Die Rotation läuft jetzt in fünf Schritten: Nachfolger erzeugen, per Dry-Run neben den alten verteilen, Anmeldung je Host prüfen, umstellen, alten Key aus allen Blöcken entfernen und seinen Private Key verwerfen. Der Nachfolger steht während der Rotation in Block und Pflichtliste, damit kein Host ohne funktionierenden Key dasteht. Umgestellt wird nur nach erfolgreicher Prüfung oder bewusstem Übergehen eines Hosts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
207 lines
7.3 KiB
TypeScript
207 lines
7.3 KiB
TypeScript
import type { AbweichungsArt } from "@/generated/prisma/enums";
|
||
import { prisma } from "@/lib/db";
|
||
import { aktionText } from "@/lib/protokoll-aktionen";
|
||
|
||
/**
|
||
* Lesemodell der Übersicht (#026). Alle Zahlen werden berechnet, nichts ist hinterlegt –
|
||
* Kacheln und Listen können so nie einander widersprechen.
|
||
*/
|
||
const TAG_MS = 86_400_000;
|
||
|
||
const BEFUND_TEXT: Record<AbweichungsArt, [string, string]> = {
|
||
UNBEKANNTER_SCHLUESSEL: ["unbekannter Schlüssel", "unbekannte Schlüssel"],
|
||
FEHLENDER_SCHLUESSEL: ["Schlüssel fehlt", "Schlüssel fehlen"],
|
||
ZUSAETZLICH_IM_BLOCK: ["Block von Hand geändert", "Block von Hand geändert"],
|
||
DATEI_GEAENDERT: ["Block beschädigt", "Blöcke beschädigt"],
|
||
RECHTE: ["Rechte falsch", "Rechte falsch"],
|
||
HOSTKEY_GEAENDERT: ["Host-Key geändert", "Host-Key geändert"],
|
||
PRIVATE_KEY_FEHLT: ["Private Key fehlt", "Private Keys fehlen"],
|
||
};
|
||
|
||
/** „2 unbekannte Schlüssel · Rechte falsch“ aus den Befunden eines Host/Benutzer-Paars. */
|
||
export function befundZusammenfassen(arten: readonly AbweichungsArt[]): string {
|
||
const anzahl = new Map<AbweichungsArt, number>();
|
||
for (const art of arten) anzahl.set(art, (anzahl.get(art) ?? 0) + 1);
|
||
// Feste Reihenfolge (die von BEFUND_TEXT), damit die Zeile nicht je nach Datenbank springt.
|
||
return (Object.keys(BEFUND_TEXT) as AbweichungsArt[])
|
||
.filter((art) => anzahl.has(art))
|
||
.map((art) => [art, anzahl.get(art)!] as const)
|
||
.map(([art, n]) => {
|
||
const [einzahl, mehrzahl] = BEFUND_TEXT[art];
|
||
return n === 1 ? (art === "UNBEKANNTER_SCHLUESSEL" ? `1 ${einzahl}` : einzahl) : `${n} ${mehrzahl}`;
|
||
})
|
||
.join(" · ");
|
||
}
|
||
|
||
export type AktionsArt =
|
||
| "ausgerollt"
|
||
| "widerrufen"
|
||
| "erstellt"
|
||
| "abgleich"
|
||
| "rotiert"
|
||
| "anmeldung"
|
||
| "sonstiges"
|
||
| "verweigert";
|
||
|
||
/** Grobe Einordnung fuer die Farbe in „Letzte Aktionen“; die Ansicht im Audit-Log ist genauer (#027). */
|
||
export function aktionEinordnen(aktion: string, ergebnis: string): AktionsArt {
|
||
if (ergebnis !== "ERFOLG") return "verweigert";
|
||
if (aktion.startsWith("authorized_keys.") || aktion.startsWith("privatekey.ausgerollt"))
|
||
return "ausgerollt";
|
||
if (aktion.includes("widerruf")) return "widerrufen";
|
||
if (aktion.includes("rotation") || aktion === "deploymentkey.umgestellt") return "rotiert";
|
||
if (aktion === "abgleich") return "abgleich";
|
||
if (aktion.startsWith("anmeldung") || aktion.startsWith("bestaetigung")) return "anmeldung";
|
||
if (
|
||
aktion.endsWith(".erzeugt") ||
|
||
aktion.endsWith(".angelegt") ||
|
||
aktion.endsWith(".importiert") ||
|
||
aktion.endsWith(".uebernommen")
|
||
) {
|
||
return "erstellt";
|
||
}
|
||
return "sonstiges";
|
||
}
|
||
|
||
export async function uebersicht(jetzt = new Date()) {
|
||
const in30Tagen = new Date(jetzt.getTime() + 30 * TAG_MS);
|
||
const [hosts, schluesselGesamt, schluesselPrivat, offen, ablaufend, aktionen] = await Promise.all([
|
||
prisma.host.findMany({
|
||
where: { archiviertAm: null },
|
||
select: {
|
||
id: true,
|
||
name: true,
|
||
erreichbar: true,
|
||
driftZustand: true,
|
||
letzterAbgleichAm: true,
|
||
bootstrapAm: true,
|
||
},
|
||
}),
|
||
prisma.schluessel.count({ where: { status: "AKTIV", kategorie: { not: "DEPLOYMENT" } } }),
|
||
prisma.schluessel.count({
|
||
where: { status: "AKTIV", kategorie: { not: "DEPLOYMENT" }, masterKeyVersion: { not: null } },
|
||
}),
|
||
prisma.abweichung.findMany({
|
||
where: { behobenAm: null, host: { archiviertAm: null } },
|
||
select: { art: true, zielbenutzer: true, hostId: true, host: { select: { name: true } } },
|
||
}),
|
||
prisma.schluessel.findMany({
|
||
where: { status: "AKTIV", ablaufAm: { not: null, lte: in30Tagen } },
|
||
select: {
|
||
id: true,
|
||
name: true,
|
||
typ: true,
|
||
kategorie: true,
|
||
ablaufAm: true,
|
||
person: { select: { name: true } },
|
||
_count: { select: { publicVerwendungen: { where: { zustand: { not: "ENTFERNT" } } } } },
|
||
},
|
||
orderBy: { ablaufAm: "asc" },
|
||
}),
|
||
// Anmeldungen sind im Audit-Log zu sehen; auf der Uebersicht verdraengten sie sonst alles.
|
||
prisma.protokoll.findMany({
|
||
where: { NOT: [{ aktion: { startsWith: "anmeldung" } }, { aktion: { startsWith: "bestaetigung" } }] },
|
||
orderBy: { id: "desc" },
|
||
take: 8,
|
||
select: {
|
||
id: true,
|
||
zeitpunkt: true,
|
||
akteurName: true,
|
||
aktion: true,
|
||
ergebnis: true,
|
||
hostName: true,
|
||
zielbenutzer: true,
|
||
},
|
||
}),
|
||
]);
|
||
|
||
// Befunde je Host und Benutzer zusammenfassen; nicht erreichbare Hosts zaehlen mit.
|
||
const gruppen = new Map<
|
||
string,
|
||
{ hostId: string; host: string; benutzer: string; arten: AbweichungsArt[] }
|
||
>();
|
||
for (const a of offen) {
|
||
const schluessel = `${a.hostId}\u0000${a.zielbenutzer ?? ""}`;
|
||
const gruppe = gruppen.get(schluessel) ?? {
|
||
hostId: a.hostId,
|
||
host: a.host.name,
|
||
benutzer: a.zielbenutzer ?? "–",
|
||
arten: [],
|
||
};
|
||
gruppe.arten.push(a.art);
|
||
gruppen.set(schluessel, gruppe);
|
||
}
|
||
const mitAbweichung = [
|
||
...[...gruppen.values()].map((g) => ({
|
||
hostId: g.hostId,
|
||
host: g.host,
|
||
benutzer: g.benutzer,
|
||
status: "drift" as const,
|
||
befund: befundZusammenfassen(g.arten),
|
||
})),
|
||
...hosts
|
||
.filter((h) => h.erreichbar === false)
|
||
.map((h) => ({
|
||
hostId: h.id,
|
||
host: h.name,
|
||
benutzer: "–",
|
||
status: "unerreichbar" as const,
|
||
befund: "nicht erreichbar",
|
||
})),
|
||
].sort((a, b) => a.host.localeCompare(b.host));
|
||
|
||
const letzterAbgleich = hosts.reduce<Date | null>(
|
||
(spaetester, h) =>
|
||
h.letzterAbgleichAm && (!spaetester || h.letzterAbgleichAm > spaetester)
|
||
? h.letzterAbgleichAm
|
||
: spaetester,
|
||
null,
|
||
);
|
||
const abgelaufen = ablaufend.filter((s) => s.ablaufAm! < jetzt).length;
|
||
|
||
return {
|
||
letzterAbgleich,
|
||
kennzahlen: {
|
||
hosts: {
|
||
wert: hosts.length,
|
||
hinweis: `${hosts.filter((h) => h.driftZustand === "SYNCHRON").length} synchron · ${hosts.filter((h) => h.erreichbar === false).length} nicht erreichbar`,
|
||
},
|
||
schluessel: { wert: schluesselGesamt, hinweis: `davon ${schluesselPrivat} mit Private Key` },
|
||
abweichungen: {
|
||
wert: offen.length,
|
||
hinweis: `auf ${new Set(offen.map((a) => a.hostId)).size} Host(s)`,
|
||
},
|
||
ablaufend: {
|
||
wert: ablaufend.length - abgelaufen,
|
||
hinweis: abgelaufen ? `${abgelaufen} bereits abgelaufen` : "in den nächsten 30 Tagen",
|
||
},
|
||
},
|
||
mitAbweichung: mitAbweichung.slice(0, 8),
|
||
weitereAbweichungen: Math.max(0, mitAbweichung.length - 8),
|
||
baldAblaufend: ablaufend.slice(0, 6).map((s) => ({
|
||
id: s.id,
|
||
zweck: s.name,
|
||
person: s.person?.name ?? (s.kategorie === "DIENST" ? "Dienst" : "Person"),
|
||
typ: s.typ === "RSA4096" ? "rsa-4096" : s.typ === "ED25519" ? "ed25519" : "anderer",
|
||
ablauf: s.ablaufAm!,
|
||
ausgerollt: s._count.publicVerwendungen > 0,
|
||
})),
|
||
letzteAktionen: aktionen.map((a) => ({
|
||
id: a.id.toString(),
|
||
zeit: a.zeitpunkt,
|
||
art: aktionEinordnen(a.aktion, a.ergebnis),
|
||
aktion: a.aktion,
|
||
// Lesbarer Text aus dem Audit-Log (#027) statt des technischen Aktionsnamens.
|
||
beschreibung: [
|
||
aktionText(a.aktion),
|
||
a.zielbenutzer && a.hostName ? `${a.zielbenutzer}@${a.hostName}` : a.hostName,
|
||
]
|
||
.filter(Boolean)
|
||
.join(" · "),
|
||
benutzer: a.akteurName,
|
||
})),
|
||
};
|
||
}
|
||
|
||
export type Uebersicht = Awaited<ReturnType<typeof uebersicht>>;
|