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,139 @@
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Karte } from "@/components/ui/Karte";
|
||||
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||
import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
|
||||
import { datumZeit } from "@/lib/format";
|
||||
import { abgleichAktion } from "@/server/actions/abgleich";
|
||||
import type { IstZustand } from "@/server/queries/abgleich";
|
||||
import { UebernehmenDialog } from "./UebernehmenDialog";
|
||||
|
||||
/** Abweichungen und Ist-Zustand eines Hosts (#021, #022). */
|
||||
const ART_TEXT: 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",
|
||||
};
|
||||
|
||||
const HERKUNFT: Record<string, { text: string; variante: "ok" | "neutral" | "gefahr" }> = {
|
||||
verwaltet: { text: "verwaltet", variante: "ok" },
|
||||
bekannt: { text: "bekannt", variante: "neutral" },
|
||||
fremd: { text: "unbekannt", variante: "gefahr" },
|
||||
};
|
||||
|
||||
export function AbgleichKarten({
|
||||
hostId,
|
||||
zustand,
|
||||
darfAbgleichen,
|
||||
darfUebernehmen,
|
||||
darfVerwalten,
|
||||
}: {
|
||||
hostId: string;
|
||||
zustand: IstZustand;
|
||||
darfAbgleichen: boolean;
|
||||
darfUebernehmen: boolean;
|
||||
darfVerwalten: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Karte
|
||||
titel="Abweichungen"
|
||||
untertitel="Unterschiede zwischen Soll und Host beim letzten Abgleich"
|
||||
aktionen={
|
||||
darfAbgleichen ? (
|
||||
<VorgangsKnopf aktion={abgleichAktion.bind(null, hostId)} beschriftung="Jetzt abgleichen" />
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{zustand.abweichungen.length === 0 ? (
|
||||
<p className="text-ok text-[14px]">● Keine offenen Abweichungen.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col">
|
||||
{zustand.abweichungen.map((a) => (
|
||||
<li
|
||||
key={a.id}
|
||||
className="border-border flex flex-col gap-1 border-b py-3 first:pt-0 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<span className="text-danger text-[14px] font-medium">
|
||||
● {ART_TEXT[a.art] ?? a.art}
|
||||
{a.zielbenutzer && (
|
||||
<span className="text-muted font-mono text-[13px]"> · {a.zielbenutzer}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-text text-[14px]">{a.text}</span>
|
||||
<span className="text-muted font-mono text-[12px] break-all">
|
||||
{a.fingerprint ? `${a.fingerprint} · ` : ""}seit {datumZeit(a.eroeffnetAm)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Karte>
|
||||
|
||||
<Karte
|
||||
titel="Ist-Zustand je Benutzer"
|
||||
untertitel="Alle Schlüssel in authorized_keys – auch die, die sshkv nicht verwaltet"
|
||||
>
|
||||
{zustand.beobachtungen.length === 0 ? (
|
||||
<Leerzustand
|
||||
titel="Noch nicht abgeglichen"
|
||||
text="Nach dem ersten Abgleich steht hier, was wirklich auf dem Host liegt."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
{zustand.beobachtungen.map((b) => (
|
||||
<section key={b.zielbenutzer} className="flex flex-col gap-2">
|
||||
<h3 className="text-text font-mono text-[14px] font-medium">
|
||||
{b.zielbenutzer}{" "}
|
||||
<span className="text-muted text-[12px]">· {datumZeit(b.beobachtetAm)}</span>
|
||||
</h3>
|
||||
{b.eintraege.length === 0 ? (
|
||||
<p className="text-muted text-[13.5px]">Datei leer.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{b.eintraege.map((e) => {
|
||||
const herkunft = HERKUNFT[e.herkunft]!;
|
||||
return (
|
||||
<li
|
||||
key={`${e.fingerprint}-${e.imBlock}`}
|
||||
className="border-border rounded-box flex flex-col gap-2 border p-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-mono text-[12.5px] break-all">
|
||||
<span className="text-muted">{e.typ}</span> {e.fingerprint}
|
||||
</p>
|
||||
<p className="text-muted font-mono text-[12px] break-all">
|
||||
{e.kommentar || "(ohne Kommentar)"}
|
||||
{e.optionen ? ` · ${e.optionen}` : ""}
|
||||
{e.imBlock ? " · im verwalteten Block" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Badge variante={herkunft.variante}>{herkunft.text}</Badge>
|
||||
{e.herkunft === "fremd" && darfUebernehmen && (
|
||||
<UebernehmenDialog
|
||||
hostId={hostId}
|
||||
zielbenutzer={b.zielbenutzer}
|
||||
fingerprint={e.fingerprint}
|
||||
kommentar={e.kommentar}
|
||||
personen={zustand.personen}
|
||||
darfVerwalten={darfVerwalten}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Karte>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { alleAbgleichenAktion } from "@/server/actions/abgleich";
|
||||
|
||||
/** „Jetzt abgleichen“ auf der Uebersicht (#021): reiht fuer jeden Host einen Abgleich ein. */
|
||||
export function AlleAbgleichenKnopf() {
|
||||
const [meldung, setMeldung] = useState<string>();
|
||||
const [laeuft, starten] = useTransition();
|
||||
return (
|
||||
<span className="inline-flex flex-col items-start gap-1">
|
||||
<Button
|
||||
icon={<RefreshCw className="size-4" />}
|
||||
disabled={laeuft}
|
||||
onClick={() =>
|
||||
starten(async () => {
|
||||
const antwort = await alleAbgleichenAktion();
|
||||
setMeldung(antwort.fehler ?? `${antwort.anzahl ?? 0} Abgleich(e) eingereiht.`);
|
||||
})
|
||||
}
|
||||
>
|
||||
Jetzt abgleichen
|
||||
</Button>
|
||||
{meldung && (
|
||||
<span role="status" className="text-muted text-[12.5px]">
|
||||
{meldung}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Ankreuzfeld, Button, Feld, Formularfehler } from "@/components/ui";
|
||||
import { Auswahlliste } from "@/components/ui/Feld";
|
||||
import { useFormularAktion } from "@/components/hosts/useFormularAktion";
|
||||
import { uebernehmenAktion } from "@/server/actions/abgleich";
|
||||
|
||||
/** Fremden Key aus dem Ist-Zustand uebernehmen (#022). */
|
||||
export function UebernehmenDialog({
|
||||
hostId,
|
||||
zielbenutzer,
|
||||
fingerprint,
|
||||
kommentar,
|
||||
personen,
|
||||
darfVerwalten,
|
||||
}: {
|
||||
hostId: string;
|
||||
zielbenutzer: string;
|
||||
fingerprint: string;
|
||||
kommentar: string;
|
||||
personen: { id: string; name: string }[];
|
||||
darfVerwalten: boolean;
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const [offen, setOffen] = useState(false);
|
||||
const [verwalten, setVerwalten] = useState(darfVerwalten);
|
||||
const { zustand, absenden, laeuft } = useFormularAktion(async (vorher, formular) => {
|
||||
const ergebnis = await uebernehmenAktion(vorher, formular);
|
||||
if (!ergebnis.fehler && !ergebnis.feldfehler) setOffen(false);
|
||||
return ergebnis;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const element = dialog.current;
|
||||
if (!element) return;
|
||||
if (offen && !element.open) element.showModal();
|
||||
if (!offen && element.open) element.close();
|
||||
}, [offen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button klein onClick={() => setOffen(true)}>
|
||||
Übernehmen
|
||||
</Button>
|
||||
<dialog
|
||||
ref={dialog}
|
||||
aria-label="Schlüssel übernehmen"
|
||||
onClose={() => setOffen(false)}
|
||||
className="border-border-strong bg-surface text-text rounded-karte m-auto w-[calc(100%-32px)] max-w-lg border p-5 backdrop:bg-black/70 sm:p-6"
|
||||
>
|
||||
<h2 className="font-display mb-1 text-[18px] font-semibold">Schlüssel übernehmen</h2>
|
||||
<p className="text-muted mb-4 font-mono text-[12.5px] break-all">
|
||||
{zielbenutzer} · {fingerprint}
|
||||
</p>
|
||||
<form onSubmit={absenden} className="flex flex-col gap-4">
|
||||
<input type="hidden" name="hostId" value={hostId} />
|
||||
<input type="hidden" name="zielbenutzer" value={zielbenutzer} />
|
||||
<input type="hidden" name="fingerprint" value={fingerprint} />
|
||||
<Formularfehler text={zustand.fehler} />
|
||||
<Feld beschriftung="Zweck / Name" name="name" defaultValue={kommentar} required />
|
||||
<Auswahlliste beschriftung="Kategorie" name="kategorie" defaultValue="PERSON">
|
||||
<option value="PERSON">Person</option>
|
||||
<option value="DIENST">Dienst</option>
|
||||
</Auswahlliste>
|
||||
<Auswahlliste beschriftung="Person (optional)" name="personId" defaultValue="">
|
||||
<option value="">– keine –</option>
|
||||
{personen.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</Auswahlliste>
|
||||
{darfVerwalten && (
|
||||
<Ankreuzfeld
|
||||
name="verwalten"
|
||||
value="ja"
|
||||
checked={verwalten}
|
||||
onChange={(e) => setVerwalten(e.target.checked)}
|
||||
beschriftung="In den verwalteten Block holen"
|
||||
erklaerung="Beim nächsten Ausrollen wandert die Zeile in den Block, die alte Zeile außerhalb wird entfernt – mit Dry-Run."
|
||||
/>
|
||||
)}
|
||||
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||||
{laeuft ? "Übernehme …" : "Übernehmen"}
|
||||
</Button>
|
||||
</form>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user