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:
@@ -18,7 +18,9 @@ import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { hostDetail, type HostDetail } from "@/server/queries/hosts";
|
||||
import { BootstrapAktionen } from "./BootstrapAktionen";
|
||||
import { sshdHinweise } from "@/lib/sshd-hinweise";
|
||||
import { AbgleichKarten } from "@/components/abgleich/AbgleichKarten";
|
||||
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
||||
import { istZustand } from "@/server/queries/abgleich";
|
||||
import { VerwendungsZustand } from "@/components/ausrollen/VerwendungsZustand";
|
||||
import { verwendungenAufHost } from "@/server/queries/ziele";
|
||||
import { HostKeyAktionen } from "./HostKeyAktionen";
|
||||
@@ -100,6 +102,7 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri
|
||||
const darfEntscheiden = darf(benutzer.rolle, "hostKeyEntscheiden") && !archiviert;
|
||||
const darfAusrollen = darf(benutzer.rolle, "publicKeyAusrollen") && !archiviert;
|
||||
const verwendungen = await verwendungenAufHost(host.id);
|
||||
const ist = await istZustand(host.id);
|
||||
const jetzt = new Date();
|
||||
|
||||
return (
|
||||
@@ -295,6 +298,16 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri
|
||||
)}
|
||||
</Karte>
|
||||
|
||||
{host.bootstrapAm && (
|
||||
<AbgleichKarten
|
||||
hostId={host.id}
|
||||
zustand={ist}
|
||||
darfAbgleichen={verwalten}
|
||||
darfUebernehmen={darf(benutzer.rolle, "schluesselVerwalten") && !archiviert}
|
||||
darfVerwalten={darfAusrollen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{verwalten && (
|
||||
<Karte titel="Archivieren" untertitel="Host stilllegen, ohne die Historie zu verlieren">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ArrowRight, Plus, RefreshCw, Route } from "lucide-react";
|
||||
import { ArrowRight, Plus, Route } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
letzterAbgleich,
|
||||
type AktionsArt,
|
||||
} from "@/beispiel/uebersicht";
|
||||
import { AlleAbgleichenKnopf } from "@/components/abgleich/AlleAbgleichenKnopf";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Karte } from "@/components/ui/Karte";
|
||||
@@ -52,7 +53,7 @@ export default async function Uebersicht() {
|
||||
}
|
||||
aktionen={
|
||||
<>
|
||||
<Button icon={<RefreshCw className="size-4" />}>Jetzt abgleichen</Button>
|
||||
<AlleAbgleichenKnopf />
|
||||
<Button href="/assistent" icon={<Route className="size-4" />}>
|
||||
Host-zu-Host-Assistent
|
||||
</Button>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
authorizedKeysLesen,
|
||||
blockSetzen,
|
||||
type SchluesselZeile,
|
||||
type SollEintrag,
|
||||
} from "@/lib/ssh/authorized-keys";
|
||||
|
||||
/**
|
||||
* Abgleich einer authorized_keys mit dem Soll (#021) – rein, damit jede Befundregel einzeln
|
||||
* testbar ist. Der Worker liefert Inhalt und Rechte, die Datenbank Soll und bekannte Keys.
|
||||
*
|
||||
* Befundarten:
|
||||
* - FEHLENDER_SCHLUESSEL: ein zugewiesener Key fehlt im verwalteten Block.
|
||||
* - ZUSAETZLICH_IM_BLOCK: im Block steht etwas, das nicht (mehr) zugewiesen ist – oder mit
|
||||
* anderen Optionen. Jemand hat den Block von Hand bearbeitet.
|
||||
* - UNBEKANNTER_SCHLUESSEL: ausserhalb des Blocks steht ein Key, den sshkv gar nicht kennt.
|
||||
* Bekannte Keys ausserhalb des Blocks sind kein Befund (sie wurden bewusst dort gelassen).
|
||||
* - RECHTE: ~/.ssh oder die Datei sind fuer Gruppe/Welt beschreibbar (sshd ignoriert sie dann
|
||||
* bei StrictModes) oder gehoeren nicht dem Benutzer.
|
||||
* - DATEI_GEAENDERT: der verwaltete Block ist beschaedigt (Anfang ohne Ende o. Ae.).
|
||||
*/
|
||||
export type Befund = {
|
||||
readonly art:
|
||||
"FEHLENDER_SCHLUESSEL" | "ZUSAETZLICH_IM_BLOCK" | "UNBEKANNTER_SCHLUESSEL" | "RECHTE" | "DATEI_GEAENDERT";
|
||||
readonly fingerprint?: string;
|
||||
readonly text: string;
|
||||
};
|
||||
|
||||
/** Was der Hostdetail-Seite je Zeile gezeigt wird – nur oeffentliche Angaben. */
|
||||
export type IstEintrag = {
|
||||
readonly fingerprint: string;
|
||||
readonly typ: string;
|
||||
readonly kommentar: string;
|
||||
readonly optionen: string | null;
|
||||
readonly imBlock: boolean;
|
||||
/** verwaltet = im Soll; bekannt = in sshkv, aber hier nicht zugewiesen; fremd = unbekannt. */
|
||||
readonly herkunft: "verwaltet" | "bekannt" | "fremd";
|
||||
/** Die ganze Zeile, damit sich ein fremder Key uebernehmen laesst (#022). */
|
||||
readonly zeile: string;
|
||||
};
|
||||
|
||||
export type DateiRechte = {
|
||||
/** Oktal wie `stat -c %a`, z. B. "700". */
|
||||
readonly sshVerzeichnis: string | null;
|
||||
readonly datei: string | null;
|
||||
readonly eigentuemerPasst: boolean;
|
||||
};
|
||||
|
||||
export type AbgleichErgebnis = {
|
||||
readonly befunde: Befund[];
|
||||
readonly eintraege: IstEintrag[];
|
||||
/** Fingerprints des Solls, die im Block korrekt stehen. */
|
||||
readonly vorhanden: string[];
|
||||
};
|
||||
|
||||
function beschreibbarFuerAndere(modus: string | null): boolean {
|
||||
if (!modus) return false;
|
||||
const zahl = Number.parseInt(modus, 8);
|
||||
return Number.isFinite(zahl) && (zahl & 0o022) !== 0;
|
||||
}
|
||||
|
||||
function zeileText(zeile: SchluesselZeile): string {
|
||||
return zeile.roh.endsWith("\r") ? zeile.roh.slice(0, -1) : zeile.roh;
|
||||
}
|
||||
|
||||
export function authorizedKeysAbgleichen(
|
||||
inhalt: string | null,
|
||||
soll: readonly SollEintrag[],
|
||||
bekannteFingerprints: ReadonlySet<string>,
|
||||
rechte?: DateiRechte,
|
||||
): AbgleichErgebnis {
|
||||
const befunde: Befund[] = [];
|
||||
const datei = authorizedKeysLesen(inhalt ?? "");
|
||||
|
||||
if (datei.block.art === "beschaedigt") {
|
||||
befunde.push({ art: "DATEI_GEAENDERT", text: `Verwalteter Block beschädigt: ${datei.block.grund}` });
|
||||
}
|
||||
if (rechte) {
|
||||
if (beschreibbarFuerAndere(rechte.sshVerzeichnis)) {
|
||||
befunde.push({ art: "RECHTE", text: `~/.ssh hat die Rechte ${rechte.sshVerzeichnis} (erwartet 700).` });
|
||||
}
|
||||
if (beschreibbarFuerAndere(rechte.datei)) {
|
||||
befunde.push({ art: "RECHTE", text: `authorized_keys hat die Rechte ${rechte.datei} (erwartet 600).` });
|
||||
}
|
||||
if (!rechte.eigentuemerPasst) {
|
||||
befunde.push({ art: "RECHTE", text: "~/.ssh oder authorized_keys gehört nicht dem Benutzer." });
|
||||
}
|
||||
}
|
||||
|
||||
const sollNachFingerprint = new Map(soll.map((e) => [e.fingerprint, e]));
|
||||
const schluessel = datei.zeilen.filter((z): z is SchluesselZeile => z.art === "schluessel");
|
||||
const imBlock = schluessel.filter((z) => z.imBlock);
|
||||
|
||||
// Erwartete Zeile im Block, genau wie blockSetzen sie schreibt – dann faellt auch eine
|
||||
// geaenderte Option auf.
|
||||
const erwartet = new Map<string, string>();
|
||||
if (soll.length) {
|
||||
const muster = authorizedKeysLesen(blockSetzen("", soll).inhalt).zeilen.filter(
|
||||
(z): z is SchluesselZeile => z.art === "schluessel",
|
||||
);
|
||||
for (const zeile of muster) erwartet.set(zeile.fingerprint, zeileText(zeile));
|
||||
}
|
||||
|
||||
const vorhanden: string[] = [];
|
||||
for (const eintrag of soll) {
|
||||
const treffer = imBlock.find((z) => z.fingerprint === eintrag.fingerprint);
|
||||
if (!treffer) {
|
||||
befunde.push({
|
||||
art: "FEHLENDER_SCHLUESSEL",
|
||||
fingerprint: eintrag.fingerprint,
|
||||
text: "Zugewiesener Schlüssel fehlt im verwalteten Block.",
|
||||
});
|
||||
} else if (zeileText(treffer) !== erwartet.get(eintrag.fingerprint)) {
|
||||
befunde.push({
|
||||
art: "ZUSAETZLICH_IM_BLOCK",
|
||||
fingerprint: eintrag.fingerprint,
|
||||
text: "Schlüssel steht mit anderen Optionen im Block als zugewiesen.",
|
||||
});
|
||||
} else {
|
||||
vorhanden.push(eintrag.fingerprint);
|
||||
}
|
||||
}
|
||||
for (const zeile of imBlock) {
|
||||
if (!sollNachFingerprint.has(zeile.fingerprint)) {
|
||||
befunde.push({
|
||||
art: "ZUSAETZLICH_IM_BLOCK",
|
||||
fingerprint: zeile.fingerprint,
|
||||
text: "Im verwalteten Block steht ein Schlüssel, der hier nicht zugewiesen ist.",
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const zeile of schluessel.filter((z) => !z.imBlock)) {
|
||||
if (!bekannteFingerprints.has(zeile.fingerprint)) {
|
||||
befunde.push({
|
||||
art: "UNBEKANNTER_SCHLUESSEL",
|
||||
fingerprint: zeile.fingerprint,
|
||||
text: `Unbekannter Schlüssel außerhalb des Blocks${zeile.kommentar ? ` („${zeile.kommentar}“)` : ""}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const eintraege: IstEintrag[] = schluessel.map((zeile) => ({
|
||||
fingerprint: zeile.fingerprint,
|
||||
typ: zeile.typ,
|
||||
kommentar: zeile.kommentar,
|
||||
optionen: zeile.optionen,
|
||||
imBlock: zeile.imBlock,
|
||||
herkunft: sollNachFingerprint.has(zeile.fingerprint)
|
||||
? "verwaltet"
|
||||
: bekannteFingerprints.has(zeile.fingerprint)
|
||||
? "bekannt"
|
||||
: "fremd",
|
||||
zeile: zeileText(zeile),
|
||||
}));
|
||||
|
||||
// Doppelte Befunde (derselbe Key zweimal ausserhalb) nur einmal.
|
||||
const gesehen = new Set<string>();
|
||||
const eindeutig = befunde.filter((b) => {
|
||||
const schluesselText = `${b.art}\u0000${b.fingerprint ?? b.text}`;
|
||||
if (gesehen.has(schluesselText)) return false;
|
||||
gesehen.add(schluesselText);
|
||||
return true;
|
||||
});
|
||||
return { befunde: eindeutig, eintraege, vorhanden };
|
||||
}
|
||||
@@ -197,6 +197,12 @@ export function blockSetzen(
|
||||
inhalt: string,
|
||||
soll: readonly SollEintrag[],
|
||||
pflicht: readonly string[] = [],
|
||||
/**
|
||||
* Fingerprints, deren Zeilen ausserhalb des Blocks entfernt werden (#022): nur fuer Keys,
|
||||
* die ein Mensch ausdruecklich in die Verwaltung uebernommen hat. Sonst gilt: fremde
|
||||
* Zeilen bleiben bytegenau stehen.
|
||||
*/
|
||||
ausserhalbEntfernen: readonly string[] = [],
|
||||
): BlockErgebnis {
|
||||
const datei = authorizedKeysLesen(inhalt);
|
||||
if (datei.block.art === "beschaedigt") {
|
||||
@@ -227,11 +233,18 @@ export function blockSetzen(
|
||||
|
||||
let rohNeu: string[];
|
||||
let endetMitZeilenende = datei.endetMitZeilenende;
|
||||
const entfernen = new Set(ausserhalbEntfernen);
|
||||
const bleibt = (z: Zeile) => !(z.art === "schluessel" && !z.imBlock && entfernen.has(z.fingerprint));
|
||||
const roh = datei.zeilen.map((z) => z.roh);
|
||||
const rohOhne = (von: number, bis?: number) =>
|
||||
datei.zeilen
|
||||
.slice(von, bis)
|
||||
.filter(bleibt)
|
||||
.map((z) => z.roh);
|
||||
if (datei.block.art === "ok") {
|
||||
rohNeu = [...roh.slice(0, datei.block.anfang), ...neuerBlock, ...roh.slice(datei.block.ende + 1)];
|
||||
rohNeu = [...rohOhne(0, datei.block.anfang), ...neuerBlock, ...rohOhne(datei.block.ende + 1)];
|
||||
} else {
|
||||
rohNeu = [...roh, ...neuerBlock];
|
||||
rohNeu = [...rohOhne(0), ...neuerBlock];
|
||||
// Ein neuer Block beginnt auf einer eigenen Zeile; die bisher letzte Zeile bekommt dafuer
|
||||
// ihre fehlende Zeilenschaltung – die einzige Aenderung ausserhalb des Blocks.
|
||||
endetMitZeilenende = true;
|
||||
@@ -241,7 +254,9 @@ export function blockSetzen(
|
||||
const hinweise: string[] = [];
|
||||
const ausserhalb = datei.zeilen.filter((z): z is SchluesselZeile => z.art === "schluessel" && !z.imBlock);
|
||||
for (const eintrag of eintraege) {
|
||||
const doppelt = ausserhalb.find((z) => z.fingerprint === eintrag.fingerprint);
|
||||
const doppelt = ausserhalb.find(
|
||||
(z) => z.fingerprint === eintrag.fingerprint && !entfernen.has(z.fingerprint),
|
||||
);
|
||||
if (doppelt) {
|
||||
hinweise.push(
|
||||
`${eintrag.fingerprint} steht zusätzlich außerhalb des Blocks${doppelt.optionen ? "" : " – dort ohne Einschränkungen"}.`,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { Client } from "ssh2";
|
||||
import type { Host, Prisma } from "@/generated/prisma/client";
|
||||
import type { AbweichungsArt } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { BENUTZERNAME_MUSTER, shellQuote } from "@/lib/shell";
|
||||
import { authorizedKeysAbgleichen, type Befund, type DateiRechte } from "@/lib/ssh/abgleich";
|
||||
import { anmeldebenutzerVon, sollFuer } from "@/server/services/soll";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
import { VorgangsFehler, type Handler } from "@/worker/ausfuehren";
|
||||
import { hostLaden } from "@/worker/ablaeufe/hostkey";
|
||||
import { privateKeyLaden } from "@/worker/geheimnisse";
|
||||
import { hostVerbinden } from "@/worker/ssh/host";
|
||||
import { relativenPfadPruefen } from "@/worker/ssh/ssh-dateien";
|
||||
import { befehlAusfuehren } from "@/worker/ssh/verbindung";
|
||||
|
||||
/**
|
||||
* Abgleich eines Hosts (#021): Ist-Zustand lesen, mit dem Soll vergleichen, Befunde und
|
||||
* Zustaende festhalten. Aendert auf dem Host nichts – Korrekturen laufen ueber Dry-Run und
|
||||
* Ausrollen, damit ein Mensch sie sieht.
|
||||
*/
|
||||
|
||||
type GelesenerBenutzer = {
|
||||
readonly benutzer: string;
|
||||
readonly inhalt: string | null;
|
||||
readonly rechte?: DateiRechte;
|
||||
};
|
||||
|
||||
/**
|
||||
* Liest authorized_keys aller Benutzer mit UID 0 oder ≥ 1000 sowie der ausdruecklich
|
||||
* genannten. Inhalt als base64, damit Zeilenenden und fremde Bytes unversehrt ankommen; gelesen
|
||||
* wird als der jeweilige Benutzer (Symlink-Schutz wie beim Schreiben).
|
||||
*/
|
||||
async function authorizedKeysAllerBenutzer(
|
||||
verbindung: Client,
|
||||
host: Host,
|
||||
zusaetzlich: string[],
|
||||
): Promise<GelesenerBenutzer[]> {
|
||||
const namen = [...new Set(zusaetzlich)].filter((n) => BENUTZERNAME_MUSTER.test(n));
|
||||
const skript = `set -u
|
||||
LISTE=$( { getent passwd | awk -F: '($3==0 || $3>=1000) && $3!=65534 {print $1}'; printf '%s\\n' ${namen.map(shellQuote).join(" ")}; } | sort -u)
|
||||
for B in $LISTE; do
|
||||
H=$(getent passwd "$B" | cut -d: -f6) || continue
|
||||
[ -n "$H" ] || continue
|
||||
D="$H/.ssh/authorized_keys"
|
||||
if runuser -u "$B" -- test -e "$D" 2>/dev/null; then
|
||||
RD=$(stat -c '%a:%U' "$H/.ssh" 2>/dev/null || echo "-:-")
|
||||
RF=$(stat -c '%a:%U' "$D" 2>/dev/null || echo "-:-")
|
||||
INHALT=$(runuser -u "$B" -- base64 -w0 "$D" 2>/dev/null || echo FEHLER)
|
||||
echo "SSHKV-DATEI|$B|$RD|$RF|$INHALT"
|
||||
else
|
||||
echo "SSHKV-KEINE|$B"
|
||||
fi
|
||||
done
|
||||
echo SSHKV-ENDE`;
|
||||
const praefix = host.anmeldeart === "ROOT" ? "" : "sudo -n ";
|
||||
const ergebnis = await befehlAusfuehren(verbindung, `${praefix}sh -c ${shellQuote(skript)}`);
|
||||
if (!ergebnis.stdout.includes("SSHKV-ENDE"))
|
||||
throw new VorgangsFehler(`Abgleich auf ${host.name} ließ sich nicht ausführen.`);
|
||||
|
||||
const gelesen: GelesenerBenutzer[] = [];
|
||||
for (const zeile of ergebnis.stdout.split("\n")) {
|
||||
const teile = zeile.split("|");
|
||||
if (teile[0] === "SSHKV-KEINE" && teile[1]) gelesen.push({ benutzer: teile[1], inhalt: null });
|
||||
if (teile[0] === "SSHKV-DATEI" && teile.length === 5) {
|
||||
const [, benutzer, rd, rf, inhalt] = teile as [string, string, string, string, string];
|
||||
const [modusD, besitzerD] = rd.split(":");
|
||||
const [modusF, besitzerF] = rf.split(":");
|
||||
gelesen.push({
|
||||
benutzer,
|
||||
inhalt: inhalt === "FEHLER" ? null : Buffer.from(inhalt, "base64").toString("utf8"),
|
||||
rechte: {
|
||||
sshVerzeichnis: modusD && modusD !== "-" ? modusD : null,
|
||||
datei: modusF && modusF !== "-" ? modusF : null,
|
||||
eigentuemerPasst: besitzerD === benutzer && besitzerF === benutzer,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return gelesen;
|
||||
}
|
||||
|
||||
/** Prueft ausgerollte Private Keys: Datei da und unveraendert? */
|
||||
async function privateKeysPruefen(
|
||||
verbindung: Client,
|
||||
host: Host,
|
||||
): Promise<{ befunde: (Befund & { benutzer: string })[]; ok: string[]; fehlt: string[] }> {
|
||||
const verwendungen = await prisma.privateVerwendung.findMany({
|
||||
where: { hostId: host.id, zustand: { in: ["AUSGEROLLT", "ABWEICHUNG"] } },
|
||||
select: {
|
||||
id: true,
|
||||
zielbenutzer: true,
|
||||
pfad: true,
|
||||
schluesselId: true,
|
||||
schluessel: { select: { fingerprint: true, masterKeyVersion: true } },
|
||||
},
|
||||
});
|
||||
const befunde: (Befund & { benutzer: string })[] = [];
|
||||
const ok: string[] = [];
|
||||
const fehlt: string[] = [];
|
||||
const praefix = host.anmeldeart === "ROOT" ? "" : "sudo -n ";
|
||||
for (const v of verwendungen) {
|
||||
const relativ = relativenPfadPruefen(v.pfad);
|
||||
const skript = `H=$(getent passwd ${shellQuote(v.zielbenutzer)} | cut -d: -f6); runuser -u ${shellQuote(v.zielbenutzer)} -- sha256sum "$H/"${shellQuote(relativ)} 2>/dev/null | cut -d' ' -f1`;
|
||||
const ergebnis = await befehlAusfuehren(verbindung, `${praefix}sh -c ${shellQuote(skript)}`);
|
||||
const ist = ergebnis.stdout.trim();
|
||||
let erwartet: string | null = null;
|
||||
// Ist der Private Key aus sshkv geloescht, laesst sich nur noch das Vorhandensein pruefen.
|
||||
if (v.schluessel.masterKeyVersion !== null) {
|
||||
const klartext = await privateKeyLaden(v.schluesselId);
|
||||
erwartet = createHash("sha256").update(klartext).digest("hex");
|
||||
klartext.fill(0);
|
||||
}
|
||||
if (!ist || (erwartet && ist !== erwartet)) {
|
||||
fehlt.push(v.id);
|
||||
befunde.push({
|
||||
benutzer: v.zielbenutzer,
|
||||
art: "FEHLENDER_SCHLUESSEL",
|
||||
fingerprint: v.schluessel.fingerprint,
|
||||
text: ist ? `Private Key unter ${v.pfad} wurde verändert.` : `Private Key unter ${v.pfad} fehlt.`,
|
||||
});
|
||||
} else {
|
||||
ok.push(v.id);
|
||||
}
|
||||
}
|
||||
return { befunde, ok, fehlt };
|
||||
}
|
||||
|
||||
function befundSchluessel(
|
||||
art: string,
|
||||
benutzer: string | null,
|
||||
fingerprint: string | null,
|
||||
text: string,
|
||||
): string {
|
||||
return [art, benutzer ?? "", fingerprint ?? text].join("\u0000");
|
||||
}
|
||||
|
||||
export async function hostAbgleichen(host: Host): Promise<{ befunde: number; benutzer: number }> {
|
||||
const publicVerwendungen = await prisma.publicVerwendung.findMany({
|
||||
where: {
|
||||
zustand: { not: "ENTFERNT" },
|
||||
OR: [{ hostId: host.id }, { hostgruppe: { mitglieder: { some: { hostId: host.id } } } }],
|
||||
},
|
||||
select: { zielbenutzer: true },
|
||||
});
|
||||
const sollBenutzer = [
|
||||
...new Set([anmeldebenutzerVon(host), ...publicVerwendungen.map((v) => v.zielbenutzer)]),
|
||||
];
|
||||
|
||||
const verbindung = await hostVerbinden(host);
|
||||
let gelesen: GelesenerBenutzer[];
|
||||
let privat: Awaited<ReturnType<typeof privateKeysPruefen>>;
|
||||
try {
|
||||
gelesen = await authorizedKeysAllerBenutzer(verbindung, host, sollBenutzer);
|
||||
privat = await privateKeysPruefen(verbindung, host);
|
||||
} finally {
|
||||
verbindung.end();
|
||||
}
|
||||
|
||||
const bekannt = new Set(
|
||||
(await prisma.schluessel.findMany({ select: { fingerprint: true } })).map((s) => s.fingerprint),
|
||||
);
|
||||
const aktuell = new Map<
|
||||
string,
|
||||
{ art: AbweichungsArt; benutzer: string | null; fingerprint: string | null; text: string }
|
||||
>();
|
||||
const jetzt = new Date();
|
||||
|
||||
for (const { benutzer, inhalt, rechte } of gelesen) {
|
||||
const soll = await sollFuer(host.id, benutzer);
|
||||
// Benutzer ohne Datei und ohne Soll interessieren nicht.
|
||||
if (inhalt === null && soll.eintraege.length === 0) continue;
|
||||
const ergebnis = authorizedKeysAbgleichen(inhalt, soll.eintraege, bekannt, rechte);
|
||||
for (const befund of ergebnis.befunde) {
|
||||
aktuell.set(befundSchluessel(befund.art, benutzer, befund.fingerprint ?? null, befund.text), {
|
||||
art: befund.art,
|
||||
benutzer,
|
||||
fingerprint: befund.fingerprint ?? null,
|
||||
text: befund.text,
|
||||
});
|
||||
}
|
||||
const datei = ".ssh/authorized_keys";
|
||||
await prisma.beobachtung.upsert({
|
||||
where: { hostId_zielbenutzer_datei: { hostId: host.id, zielbenutzer: benutzer, datei } },
|
||||
create: {
|
||||
hostId: host.id,
|
||||
zielbenutzer: benutzer,
|
||||
datei,
|
||||
sha256: inhalt === null ? "keine-datei" : createHash("sha256").update(inhalt).digest("hex"),
|
||||
eintraege: ergebnis.eintraege as unknown as Prisma.InputJsonValue,
|
||||
beobachtetAm: jetzt,
|
||||
},
|
||||
update: {
|
||||
sha256: inhalt === null ? "keine-datei" : createHash("sha256").update(inhalt).digest("hex"),
|
||||
eintraege: ergebnis.eintraege as unknown as Prisma.InputJsonValue,
|
||||
beobachtetAm: jetzt,
|
||||
},
|
||||
});
|
||||
|
||||
// Zustand der Public-Verwendungen nachziehen: was im Block stimmt, ist synchron; was fehlt,
|
||||
// obwohl es ausgerollt war, ist Drift. GEPLANT bleibt GEPLANT, bis jemand ausrollt.
|
||||
const vorhanden = new Set(ergebnis.vorhanden);
|
||||
const verwendungen = await prisma.publicVerwendung.findMany({
|
||||
where: {
|
||||
zielbenutzer: benutzer,
|
||||
id: { in: soll.verwendungIds.drin },
|
||||
},
|
||||
select: { id: true, zustand: true, schluessel: { select: { fingerprint: true } } },
|
||||
});
|
||||
for (const v of verwendungen) {
|
||||
const da = vorhanden.has(v.schluessel.fingerprint);
|
||||
if (da && v.zustand === "ABWEICHUNG") {
|
||||
await prisma.publicVerwendung.update({ where: { id: v.id }, data: { zustand: "AUSGEROLLT" } });
|
||||
} else if (!da && v.zustand === "AUSGEROLLT") {
|
||||
await prisma.publicVerwendung.update({ where: { id: v.id }, data: { zustand: "ABWEICHUNG" } });
|
||||
}
|
||||
}
|
||||
}
|
||||
// Benutzer, die nicht mehr gelesen wurden (Datei weg), verlieren ihre Beobachtung.
|
||||
await prisma.beobachtung.deleteMany({
|
||||
where: {
|
||||
hostId: host.id,
|
||||
zielbenutzer: { notIn: gelesen.filter((g) => g.inhalt !== null).map((g) => g.benutzer) },
|
||||
},
|
||||
});
|
||||
|
||||
for (const befund of privat.befunde) {
|
||||
aktuell.set(
|
||||
befundSchluessel("PRIVATE_KEY_FEHLT", befund.benutzer, befund.fingerprint ?? null, befund.text),
|
||||
{
|
||||
art: "PRIVATE_KEY_FEHLT",
|
||||
benutzer: befund.benutzer,
|
||||
fingerprint: befund.fingerprint ?? null,
|
||||
text: befund.text,
|
||||
},
|
||||
);
|
||||
}
|
||||
await prisma.privateVerwendung.updateMany({
|
||||
where: { id: { in: privat.fehlt } },
|
||||
data: { zustand: "ABWEICHUNG" },
|
||||
});
|
||||
await prisma.privateVerwendung.updateMany({
|
||||
where: { id: { in: privat.ok }, zustand: "ABWEICHUNG" },
|
||||
data: { zustand: "AUSGEROLLT" },
|
||||
});
|
||||
|
||||
// Abweichungen: neue oeffnen, verschwundene schliessen. Host-Key-Abweichungen verwaltet #009.
|
||||
const offen = await prisma.abweichung.findMany({
|
||||
where: { hostId: host.id, behobenAm: null, art: { not: "HOSTKEY_GEAENDERT" } },
|
||||
});
|
||||
const offenSchluessel = new Map(
|
||||
offen.map((a) => [
|
||||
befundSchluessel(
|
||||
a.art,
|
||||
a.zielbenutzer,
|
||||
a.fingerprint,
|
||||
((a.details ?? {}) as { text?: string }).text ?? "",
|
||||
),
|
||||
a,
|
||||
]),
|
||||
);
|
||||
for (const [schluessel, befund] of aktuell) {
|
||||
if (offenSchluessel.has(schluessel)) continue;
|
||||
await prisma.abweichung.create({
|
||||
data: {
|
||||
hostId: host.id,
|
||||
zielbenutzer: befund.benutzer,
|
||||
art: befund.art,
|
||||
fingerprint: befund.fingerprint,
|
||||
details: { text: befund.text },
|
||||
},
|
||||
});
|
||||
}
|
||||
const behoben = [...offenSchluessel]
|
||||
.filter(([schluessel]) => !aktuell.has(schluessel))
|
||||
.map(([, a]) => a.id);
|
||||
await prisma.abweichung.updateMany({ where: { id: { in: behoben } }, data: { behobenAm: jetzt } });
|
||||
|
||||
const hostKeyOffen = await prisma.abweichung.count({
|
||||
where: { hostId: host.id, behobenAm: null, art: "HOSTKEY_GEAENDERT" },
|
||||
});
|
||||
await prisma.host.update({
|
||||
where: { id: host.id },
|
||||
data: {
|
||||
driftZustand: aktuell.size > 0 || hostKeyOffen > 0 ? "DRIFT" : "SYNCHRON",
|
||||
letzterAbgleichAm: jetzt,
|
||||
erreichbar: true,
|
||||
zuletztGeprueftAm: jetzt,
|
||||
},
|
||||
});
|
||||
return { befunde: aktuell.size, benutzer: gelesen.filter((g) => g.inhalt !== null).length };
|
||||
}
|
||||
|
||||
export const abgleichHandler: Handler = async ({ vorgang }) => {
|
||||
const host = await hostLaden(vorgang.hostId);
|
||||
if (!host.bootstrapAm) throw new VorgangsFehler("Der Host ist noch nicht eingerichtet (Bootstrap fehlt).");
|
||||
try {
|
||||
const ergebnis = await hostAbgleichen(host);
|
||||
await protokollieren({
|
||||
akteur: "System",
|
||||
aktion: "abgleich",
|
||||
ergebnis: "ERFOLG",
|
||||
hostName: host.name,
|
||||
details: ergebnis,
|
||||
});
|
||||
return { ergebnis };
|
||||
} catch (fehler) {
|
||||
await prisma.host.update({
|
||||
where: { id: host.id },
|
||||
data: { erreichbar: false, zuletztGeprueftAm: new Date() },
|
||||
});
|
||||
throw fehler;
|
||||
}
|
||||
};
|
||||
@@ -146,7 +146,7 @@ export const trockenlaufHandler: Handler = async ({ vorgang }) => {
|
||||
return privatPlanZiel(host, benutzer, await privatPlanen(verbindung, host, benutzer));
|
||||
const stand = await authorizedKeysHolen(verbindung, host, benutzer);
|
||||
const soll = await sollFuer(host.id, benutzer);
|
||||
const ergebnis = blockSetzen(stand.inhalt, soll.eintraege, soll.pflicht);
|
||||
const ergebnis = blockSetzen(stand.inhalt, soll.eintraege, soll.pflicht, soll.ausserhalbEntfernen);
|
||||
return {
|
||||
art: "public",
|
||||
hostId: host.id,
|
||||
@@ -249,7 +249,7 @@ export const ausrollenHandler: Handler = async ({ vorgang }) => {
|
||||
);
|
||||
}
|
||||
const soll = await sollFuer(host.id, benutzer);
|
||||
const ergebnis = blockSetzen(stand.inhalt, soll.eintraege, soll.pflicht);
|
||||
const ergebnis = blockSetzen(stand.inhalt, soll.eintraege, soll.pflicht, soll.ausserhalbEntfernen);
|
||||
if (hashVon(ergebnis.inhalt) !== ziel.nachher) {
|
||||
throw new VorgangsFehler(
|
||||
`Die Zuweisungen für ${benutzer}@${host.name} haben sich seit dem Trockenlauf geändert. Bitte neuen Trockenlauf starten.`,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { HandlerTabelle } from "@/worker/ausfuehren";
|
||||
import { abgleichHandler } from "@/worker/ablaeufe/abgleich";
|
||||
import { ausrollenHandler, trockenlaufHandler } from "@/worker/ablaeufe/ausrollen";
|
||||
import { bootstrapHandler } from "@/worker/ablaeufe/bootstrap";
|
||||
import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey";
|
||||
@@ -8,7 +9,7 @@ import { verbindungstestHandler } from "@/worker/ablaeufe/verbindungstest";
|
||||
import { zielkontoAnlegenHandler, zielkontoEntfernenHandler } from "@/worker/ablaeufe/zielkonto";
|
||||
|
||||
/**
|
||||
* Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Abgleich (#021) …
|
||||
* Welche Vorgangsart wer ausfuehrt.
|
||||
*/
|
||||
export const HANDLER: HandlerTabelle = {
|
||||
HOSTKEY_ABRUFEN: hostKeyAbrufenHandler,
|
||||
@@ -21,6 +22,7 @@ export const HANDLER: HandlerTabelle = {
|
||||
PRIVATE_KEY_ABHOLEN: privateKeyAbholenHandler,
|
||||
PRIVATE_KEY_LOESCHEN: privateKeyLoeschenHandler,
|
||||
VERBINDUNGSTEST: verbindungstestHandler,
|
||||
ABGLEICH: abgleichHandler,
|
||||
ZIELKONTO_ANLEGEN: zielkontoAnlegenHandler,
|
||||
ZIELKONTO_ENTFERNEN: zielkontoEntfernenHandler,
|
||||
};
|
||||
|
||||
+18
-2
@@ -1,7 +1,13 @@
|
||||
import "dotenv/config";
|
||||
import type { Job } from "pg-boss";
|
||||
import { VERSION } from "@/lib/version";
|
||||
import { WARTESCHLANGE, warteschlangeHolen, type VorgangsJob } from "@/server/vorgaenge/warteschlange";
|
||||
import { alleHostsAbgleichen } from "@/server/services/abgleich";
|
||||
import {
|
||||
WARTESCHLANGE,
|
||||
ZEITPLAN_ABGLEICH,
|
||||
warteschlangeHolen,
|
||||
type VorgangsJob,
|
||||
} from "@/server/vorgaenge/warteschlange";
|
||||
import { vorgangAusfuehren } from "@/worker/ausfuehren";
|
||||
import { abholungenAufraeumen } from "@/worker/ablaeufe/privatekey";
|
||||
import { deploymentKeySicherstellen } from "@/worker/geheimnisse";
|
||||
@@ -45,7 +51,17 @@ async function starten(): Promise<void> {
|
||||
await aufraeumen();
|
||||
const takt = setInterval(() => void aufraeumen(), 30_000);
|
||||
|
||||
console.log(`sshkv-worker ${VERSION} bereit.`);
|
||||
// Periodischer Abgleich (#021). Der Zeitplan liegt in pg-boss und damit in der Datenbank:
|
||||
// laufen spaeter mehrere Worker, feuert er trotzdem nur einmal.
|
||||
const zeitplan = process.env["SSHKV_SYNC_CRON"]?.trim() || "30 2 * * *";
|
||||
await boss.createQueue(ZEITPLAN_ABGLEICH);
|
||||
await boss.schedule(ZEITPLAN_ABGLEICH, zeitplan, null, { tz: "Europe/Berlin" });
|
||||
await boss.work(ZEITPLAN_ABGLEICH, async () => {
|
||||
const anzahl = await alleHostsAbgleichen(null);
|
||||
console.log(`Zeitplan: ${anzahl} Abgleich(e) eingereiht.`);
|
||||
});
|
||||
|
||||
console.log(`sshkv-worker ${VERSION} bereit (Abgleich: ${zeitplan}).`);
|
||||
|
||||
const beenden = async (signal: string) => {
|
||||
console.log(`${signal} empfangen, beende laufende Vorgänge …`);
|
||||
|
||||
Reference in New Issue
Block a user