Ein Abgleich liest je Host alle authorized_keys in einem SSH-Aufruf, als jeweiliger Benutzer, und vergleicht sie mit dem Soll: fehlende, von Hand geaenderte und unbekannte Keys, falsche Rechte, beschaedigte Bloecke und veraenderte Private Keys. Befunde werden geoeffnet und wieder geschlossen, Zustaende von Verwendungen und Host nachgezogen. Auf dem Host aendert der Abgleich nichts. Der Zeitplan liegt in pg-boss und feuert auch bei mehreren Workern nur einmal. Auf der Hostseite stehen Abweichungen und der Ist-Zustand je Benutzer; unbekannte Keys lassen sich uebernehmen - nur bekannt machen oder mit Dry-Run in den verwalteten Block holen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { akteurVon, fehlerZustand, textfeld, type FormularZustand } from "@/server/actions/formular";
|
||||
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { abgleichAnfordern, alleHostsAbgleichen } from "@/server/services/abgleich";
|
||||
import { keyUebernehmen } from "@/server/services/uebernahme";
|
||||
|
||||
/** Abgleich anstossen und vorhandene Keys uebernehmen (#021, #022). */
|
||||
|
||||
export async function abgleichAktion(hostId: string): Promise<{ vorgangId?: string; fehler?: string }> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("hostsVerwalten");
|
||||
return { vorgangId: await abgleichAnfordern(z.string().min(1).max(64).parse(hostId), benutzer.id) };
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof RechteFehler) return { fehler: fehler.message };
|
||||
throw fehler;
|
||||
}
|
||||
}
|
||||
|
||||
export async function alleAbgleichenAktion(): Promise<{ anzahl?: number; fehler?: string }> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("hostsVerwalten");
|
||||
return { anzahl: await alleHostsAbgleichen(benutzer.id) };
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof RechteFehler) return { fehler: fehler.message };
|
||||
throw fehler;
|
||||
}
|
||||
}
|
||||
|
||||
export async function uebernehmenAktion(
|
||||
_vorher: FormularZustand,
|
||||
formular: FormData,
|
||||
): Promise<FormularZustand> {
|
||||
const verwalten = formular.get("verwalten") === "ja";
|
||||
const hostId = textfeld(formular, "hostId") ?? "";
|
||||
try {
|
||||
// Aufnehmen ist Schluesselverwaltung; in den Block holen ist zusaetzlich Ausrollen.
|
||||
const { benutzer } = await rechtErforderlich(verwalten ? "publicKeyAusrollen" : "schluesselVerwalten");
|
||||
await keyUebernehmen(
|
||||
{
|
||||
hostId,
|
||||
zielbenutzer: textfeld(formular, "zielbenutzer") ?? "",
|
||||
fingerprint: textfeld(formular, "fingerprint") ?? "",
|
||||
name: (textfeld(formular, "name") ?? "").trim(),
|
||||
kategorie: textfeld(formular, "kategorie") === "DIENST" ? "DIENST" : "PERSON",
|
||||
personId: textfeld(formular, "personId") ?? null,
|
||||
verwalten,
|
||||
},
|
||||
akteurVon(benutzer),
|
||||
);
|
||||
} catch (fehler) {
|
||||
return fehlerZustand(fehler);
|
||||
}
|
||||
revalidatePath(`/hosts/${hostId}`);
|
||||
return {};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { IstEintrag } from "@/lib/ssh/abgleich";
|
||||
|
||||
/** Ist-Zustand und offene Abweichungen eines Hosts (#021) – nur oeffentliche Angaben. */
|
||||
export async function istZustand(hostId: string) {
|
||||
const [beobachtungen, abweichungen, personen] = await Promise.all([
|
||||
prisma.beobachtung.findMany({
|
||||
where: { hostId },
|
||||
select: { zielbenutzer: true, beobachtetAm: true, eintraege: true },
|
||||
orderBy: { zielbenutzer: "asc" },
|
||||
}),
|
||||
prisma.abweichung.findMany({
|
||||
where: { hostId, behobenAm: null },
|
||||
select: {
|
||||
id: true,
|
||||
art: true,
|
||||
zielbenutzer: true,
|
||||
fingerprint: true,
|
||||
details: true,
|
||||
eroeffnetAm: true,
|
||||
},
|
||||
orderBy: { eroeffnetAm: "asc" },
|
||||
}),
|
||||
prisma.person.findMany({
|
||||
where: { archiviertAm: null },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
beobachtungen: beobachtungen.map((b) => ({
|
||||
zielbenutzer: b.zielbenutzer,
|
||||
beobachtetAm: b.beobachtetAm,
|
||||
eintraege: b.eintraege as unknown as IstEintrag[],
|
||||
})),
|
||||
abweichungen: abweichungen.map((a) => ({
|
||||
...a,
|
||||
text:
|
||||
((a.details ?? {}) as { text?: string; erwartet?: string }).text ??
|
||||
(a.art === "HOSTKEY_GEAENDERT" ? "Host-Key hat sich geändert." : a.art),
|
||||
})),
|
||||
personen,
|
||||
};
|
||||
}
|
||||
|
||||
export type IstZustand = Awaited<ReturnType<typeof istZustand>>;
|
||||
@@ -0,0 +1,32 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||
|
||||
/**
|
||||
* Zeitplan (#021): reiht fuer jeden eingerichteten Host einen Abgleich ein – ausser fuer Hosts,
|
||||
* deren letzter Abgleich noch wartet oder laeuft.
|
||||
*/
|
||||
export async function alleHostsAbgleichen(angefordertVonId: string | null): Promise<number> {
|
||||
const hosts = await prisma.host.findMany({
|
||||
where: { archiviertAm: null, bootstrapAm: { not: null } },
|
||||
select: { id: true },
|
||||
});
|
||||
const laufend = new Set(
|
||||
(
|
||||
await prisma.vorgang.findMany({
|
||||
where: { art: "ABGLEICH", status: { in: ["WARTEND", "LAEUFT"] } },
|
||||
select: { hostId: true },
|
||||
})
|
||||
).map((v) => v.hostId),
|
||||
);
|
||||
let anzahl = 0;
|
||||
for (const host of hosts) {
|
||||
if (laufend.has(host.id)) continue;
|
||||
await vorgangEinreihen({ art: "ABGLEICH", angefordertVonId, hostId: host.id });
|
||||
anzahl += 1;
|
||||
}
|
||||
return anzahl;
|
||||
}
|
||||
|
||||
export async function abgleichAnfordern(hostId: string, angefordertVonId: string): Promise<string> {
|
||||
return vorgangEinreihen({ art: "ABGLEICH", angefordertVonId, hostId });
|
||||
}
|
||||
@@ -20,6 +20,8 @@ export type Soll = {
|
||||
readonly eintraege: SollEintrag[];
|
||||
/** Fingerprints, die nie aus dem Block fallen duerfen (Deployment-Key). */
|
||||
readonly pflicht: string[];
|
||||
/** Uebernommene Keys (#022), deren Zeile ausserhalb des Blocks verschwinden soll. */
|
||||
readonly ausserhalbEntfernen: string[];
|
||||
/** Verwendungen, die in diesem Soll stecken oder entfernt werden – fuer den Zustand danach. */
|
||||
readonly verwendungIds: { readonly drin: string[]; readonly raus: string[] };
|
||||
};
|
||||
@@ -58,6 +60,7 @@ export async function sollFuer(hostId: string, zielbenutzer: string): Promise<So
|
||||
select: {
|
||||
id: true,
|
||||
optionen: true,
|
||||
ausserhalbEntfernen: true,
|
||||
zustand: true,
|
||||
angelegtAm: true,
|
||||
schluessel: { select: { publicKey: true, fingerprint: true, status: true } },
|
||||
@@ -78,6 +81,7 @@ export async function sollFuer(hostId: string, zielbenutzer: string): Promise<So
|
||||
|
||||
const drin: string[] = [];
|
||||
const raus: string[] = [];
|
||||
const ausserhalbEntfernen: string[] = [];
|
||||
for (const verwendung of verwendungen) {
|
||||
const aktiv = verwendung.schluessel.status === "AKTIV" || verwendung.schluessel.status === "ROTIERT";
|
||||
if ((IM_SOLL as readonly string[]).includes(verwendung.zustand) && aktiv) {
|
||||
@@ -87,11 +91,12 @@ export async function sollFuer(hostId: string, zielbenutzer: string): Promise<So
|
||||
optionen: verwendung.optionen,
|
||||
});
|
||||
drin.push(verwendung.id);
|
||||
if (verwendung.ausserhalbEntfernen) ausserhalbEntfernen.push(verwendung.schluessel.fingerprint);
|
||||
} else {
|
||||
raus.push(verwendung.id);
|
||||
}
|
||||
}
|
||||
return { eintraege, pflicht, verwendungIds: { drin, raus } };
|
||||
return { eintraege, pflicht, ausserhalbEntfernen, verwendungIds: { drin, raus } };
|
||||
}
|
||||
|
||||
/** Ein Ziel des Ausrollens: authorized_keys (public) oder ~/.ssh-Dateien eines Quell-Hosts (privat). */
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { IstEintrag } from "@/lib/ssh/abgleich";
|
||||
import { authorizedKeysLesen, type SchluesselZeile } from "@/lib/ssh/authorized-keys";
|
||||
import { OptionenFehler, optionenPruefen } from "@/lib/ssh/optionen";
|
||||
import { FachFehler } from "@/server/services/fachfehler";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
import { publicKeyAnlegen } from "@/server/services/schluessel";
|
||||
|
||||
/**
|
||||
* Vorhandene Keys in die Verwaltung uebernehmen (#022). Grundlage ist die letzte Beobachtung
|
||||
* des Abgleichs – uebernommen wird nur, was sshkv dort wirklich gesehen hat.
|
||||
*
|
||||
* Zwei Wege:
|
||||
* - „bekannt“: der Key wird als Schluessel aufgenommen (mit Person/Zweck). Auf dem Host
|
||||
* aendert sich nichts, der Befund „unbekannter Schluessel“ verschwindet beim naechsten Abgleich.
|
||||
* - „verwalten“: zusaetzlich eine Zuweisung, die beim naechsten Ausrollen die Zeile in den
|
||||
* verwalteten Block holt und die alte Zeile ausserhalb entfernt (mit Dry-Run).
|
||||
*/
|
||||
export type Uebernahme = {
|
||||
readonly hostId: string;
|
||||
readonly zielbenutzer: string;
|
||||
readonly fingerprint: string;
|
||||
readonly name: string;
|
||||
readonly kategorie: "PERSON" | "DIENST";
|
||||
readonly personId: string | null;
|
||||
readonly verwalten: boolean;
|
||||
};
|
||||
|
||||
type Akteur = { readonly id: string; readonly name: string };
|
||||
|
||||
export async function keyUebernehmen(eingabe: Uebernahme, akteur: Akteur): Promise<{ schluesselId: string }> {
|
||||
const beobachtung = await prisma.beobachtung.findUnique({
|
||||
where: {
|
||||
hostId_zielbenutzer_datei: {
|
||||
hostId: eingabe.hostId,
|
||||
zielbenutzer: eingabe.zielbenutzer,
|
||||
datei: ".ssh/authorized_keys",
|
||||
},
|
||||
},
|
||||
include: { host: { select: { name: true } } },
|
||||
});
|
||||
const eintrag = ((beobachtung?.eintraege ?? []) as unknown as IstEintrag[]).find(
|
||||
(e) => e.fingerprint === eingabe.fingerprint,
|
||||
);
|
||||
if (!beobachtung || !eintrag) {
|
||||
throw new FachFehler(
|
||||
"Diesen Schlüssel hat der letzte Abgleich dort nicht gesehen. Bitte erneut abgleichen.",
|
||||
);
|
||||
}
|
||||
const zeile = authorizedKeysLesen(eintrag.zeile).zeilen.find(
|
||||
(z): z is SchluesselZeile => z.art === "schluessel",
|
||||
);
|
||||
if (!zeile) throw new FachFehler("Die Zeile ließ sich nicht lesen.");
|
||||
|
||||
let optionen: string | null = null;
|
||||
if (eingabe.verwalten && zeile.optionen) {
|
||||
try {
|
||||
optionen = optionenPruefen(zeile.optionen) || null;
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof OptionenFehler) {
|
||||
throw new FachFehler(`Die Optionen der Zeile lassen sich nicht übernehmen: ${fehler.message}`);
|
||||
}
|
||||
throw fehler;
|
||||
}
|
||||
}
|
||||
|
||||
const neu = await publicKeyAnlegen(
|
||||
{
|
||||
name: eingabe.name,
|
||||
slug: null,
|
||||
kategorie: eingabe.kategorie,
|
||||
publicKey: `${zeile.typ} ${zeile.base64}${zeile.kommentar ? ` ${zeile.kommentar}` : ""}`,
|
||||
kommentar: null,
|
||||
tags: ["übernommen"],
|
||||
ablaufAm: null,
|
||||
personId: eingabe.personId,
|
||||
vorlageId: null,
|
||||
},
|
||||
akteur,
|
||||
"schluessel.uebernommen",
|
||||
);
|
||||
|
||||
if (eingabe.verwalten) {
|
||||
await prisma.publicVerwendung.create({
|
||||
data: {
|
||||
schluesselId: neu.id,
|
||||
hostId: eingabe.hostId,
|
||||
zielbenutzer: eingabe.zielbenutzer,
|
||||
optionen,
|
||||
ausserhalbEntfernen: true,
|
||||
angelegtVonId: akteur.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
await protokollieren({
|
||||
akteur,
|
||||
aktion: eingabe.verwalten ? "uebernahme.verwalten" : "uebernahme.bekannt",
|
||||
ergebnis: "ERFOLG",
|
||||
hostName: beobachtung.host.name,
|
||||
zielbenutzer: eingabe.zielbenutzer,
|
||||
fingerprint: neu.fingerprint,
|
||||
details: { optionen },
|
||||
});
|
||||
return { schluesselId: neu.id };
|
||||
}
|
||||
Reference in New Issue
Block a user