Merge branch 'phase/021-abgleich'

This commit is contained in:
2026-10-12 13:05:24 +02:00
26 changed files with 1408 additions and 21 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

+12 -5
View File
@@ -1,20 +1,27 @@
# #021 Abgleich und Drift-Erkennung
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.4.0
**Labels:** backend
**Geschätzt:** 3 h **Tatsächlich:** –
**Geschätzt:** 3 h **Tatsächlich:** 0 h 45 min
## Ziel
Periodischer und manueller Abgleich, Befunde je Host/Benutzer.
## Aufgaben
- [ ] Zeitplan konfigurierbar
- [ ] Befundarten
- [x] Zeitplan konfigurierbar
- [x] Befundarten
## Akzeptanzkriterien
- [ ] Integrationstest
- [x] Integrationstest
## Abhängigkeiten
#016
## Notizen / Entscheidungen
- **Ein SSH-Aufruf je Host** liest `authorized_keys` aller Benutzer mit UID 0 oder ≥ 1000 plus aller Benutzer mit Zuweisungen – als jeweiliger Benutzer, base64-kodiert (Zeilenenden und fremde Bytes kommen unversehrt an). Dazu Rechte und Eigentümer von `~/.ssh` und Datei.
- **Befunde** (rein, `src/lib/ssh/abgleich.ts`): fehlender Key im Block, Block von Hand geändert (zusätzlicher Key oder andere Optionen), unbekannter Key außerhalb (bekannte Keys außerhalb sind kein Befund), Rechte (für Gruppe/Welt beschreibbar oder fremder Eigentümer), beschädigter Block, Private Key fehlt oder verändert (Hash gegen den versiegelten Key).
- **Abweichungen** werden geöffnet und – sobald behoben – geschlossen, nie doppelt. Host-Key-Abweichungen bleiben bei #009.
- **Zustände:** Public-Verwendungen AUSGEROLLT ↔ ABWEICHUNG, GEPLANT bleibt bis zum Ausrollen; Host SYNCHRON/DRIFT, letzter Abgleich, erreichbar.
- **Zeitplan** über pg-boss (`SSHKV_SYNC_CRON`, Standard `30 2 * * *`, Europe/Berlin) – liegt in der Datenbank, feuert also auch bei mehreren Workern nur einmal. Hosts mit laufendem Abgleich werden übersprungen.
- **Ändert nichts auf dem Host.** Korrekturen laufen über Dry-Run und Ausrollen.
- `DATEI_GEAENDERT` meldet nur einen beschädigten Block; eine bloße Hash-Änderung ist kein Befund, weil sshkv selbst schreibt.
+8 -4
View File
@@ -1,19 +1,23 @@
# #022 Import bestehender Schlüssel
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.4.0
**Labels:** backend, ui
**Geschätzt:** 2 h **Tatsächlich:** –
**Geschätzt:** 2 h **Tatsächlich:** 0 h 20 min
## Ziel
Unbekannte Schlüssel eines Hosts übernehmen und zuordnen.
## Aufgaben
- [ ] wird zu Beginn der Phase verfeinert
- [x] wird zu Beginn der Phase verfeinert
## Akzeptanzkriterien
- [ ] wird zu Beginn der Phase verfeinert
- [x] wird zu Beginn der Phase verfeinert
## Abhängigkeiten
#021
## Notizen / Entscheidungen
- **Grundlage ist die letzte Beobachtung** des Abgleichs: Übernommen wird nur, was sshkv dort wirklich gesehen hat.
- **Zwei Wege:** „bekannt“ (nur als Schlüssel aufnehmen – der Befund verschwindet, auf dem Host ändert sich nichts) oder „verwalten“ (Zuweisung mit `ausserhalbEntfernen`: beim nächsten Ausrollen wandert die Zeile mit ihren Optionen in den Block, die alte Zeile außerhalb verschwindet – mit Dry-Run).
- Neues Feld `PublicVerwendung.ausserhalbEntfernen` (Migration `20261012131000`). Ohne dieses Feld fasst sshkv Zeilen außerhalb des Blocks weiterhin nie an.
- Optionen der Zeile werden mit `optionenPruefen` übernommen; unbekannte Optionen verhindern das Verwalten mit klarer Meldung.
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "PublicVerwendung" ADD COLUMN "ausserhalbEntfernen" BOOLEAN NOT NULL DEFAULT false;
+3
View File
@@ -333,6 +333,9 @@ model PublicVerwendung {
zielbenutzer String
/// authorized_keys-Optionen, z. B. restrict,from="10.0.0.12".
optionen String?
/// Beim Ausrollen dieselbe Zeile ausserhalb des Blocks entfernen – nur fuer uebernommene
/// Keys (#022), sonst fasst sshkv fremde Zeilen nie an.
ausserhalbEntfernen Boolean @default(false)
zustand VerwendungsZustand @default(GEPLANT)
zuletztAngewendetAm DateTime?
angelegtVonId String?
+13
View File
@@ -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">
+3 -2
View File
@@ -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>
+139
View File
@@ -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>
</>
);
}
+165
View File
@@ -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 };
}
+18 -3
View File
@@ -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"}.`,
+58
View File
@@ -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 {};
}
+46
View File
@@ -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>>;
+32
View File
@@ -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 });
}
+6 -1
View File
@@ -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). */
+105
View File
@@ -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 };
}
+314
View File
@@ -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;
}
};
+2 -2
View File
@@ -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.`,
+3 -1
View File
@@ -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
View File
@@ -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 …`);
+74
View File
@@ -0,0 +1,74 @@
import { execFileSync } from "node:child_process";
import { expect, test } from "@playwright/test";
import "dotenv/config";
import { schluesselpaarErzeugen } from "../../src/lib/ssh/openssh";
/**
* Abgleich und Uebernahme im Browser (#021, #022), gebauter Stand + Worker, Testziel B:
* fremder Key → „Jetzt abgleichen“ zeigt ihn → übernehmen → Dry-Run → anwenden → synchron.
*/
const ROOT_PASSWORT = process.env["SSHKV_TEST_ROOT_PASSWORT"] || "sshkv-test";
const PORT_B = (process.env["SSHKV_TEST_ZIEL_B"] ?? "127.0.0.1:2222").split(":")[1] ?? "2222";
test.setTimeout(180_000);
function aufZielB(befehl: string): string {
return execFileSync(
"docker",
["compose", "-f", "docker-compose.test.yml", "exec", "-T", "ziel-b", "sh", "-c", befehl],
{
encoding: "utf8",
},
);
}
test.beforeAll(() => {
aufZielB(
"userdel -r sshkv 2>/dev/null; userdel -r deploy 2>/dev/null; rm -f /etc/sudoers.d/sshkv*; rm -rf /root/.ssh; true",
);
});
test("fremden Schlüssel finden und übernehmen", async ({ page }) => {
const name = `zz-e2e-${Date.now().toString(36)}`;
await page.goto("/anmelden");
await page.getByRole("link", { name: /Mit Mock-OIDC anmelden/ }).click();
await page.locator('button[data-rolle="admin"]').click();
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
await page.goto("/hosts/neu");
await page.getByLabel("Name").fill(name);
await page.getByLabel("Adresse").fill("127.0.0.1");
await page.getByLabel("Port").fill(PORT_B);
await page.getByRole("button", { name: "Host anlegen" }).click();
await page.getByRole("button", { name: "Host-Key abrufen" }).click();
await page.getByRole("button", { name: "Fingerprint stimmt – vertrauen" }).click({ timeout: 30_000 });
await page.getByLabel("Passwort", { exact: true }).fill(ROOT_PASSWORT);
await page.getByRole("button", { name: "Zugang einrichten" }).click();
await expect(page.getByRole("button", { name: "Verbindung prüfen" })).toBeVisible({ timeout: 60_000 });
const fremd = await schluesselpaarErzeugen("ED25519", { kommentar: `${name}@laptop` });
aufZielB(`mkdir -p /root/.ssh && echo '${fremd.publicKeyZeile}' >> /root/.ssh/authorized_keys`);
await page.getByRole("button", { name: "Jetzt abgleichen" }).click();
await expect(page.getByText(/● Unbekannter Schlüssel/)).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("unbekannt", { exact: true })).toBeVisible();
await page.screenshot({ path: "docs/anleitung/bilder/abgleich-desktop.png", fullPage: true });
await page.getByRole("button", { name: "Übernehmen", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Schlüssel übernehmen" });
await dialog.getByLabel("Zweck / Name").fill(`${name} Laptop`);
await dialog.getByRole("button", { name: "Übernehmen" }).click();
await expect(dialog).toBeHidden();
await page.getByRole("button", { name: "Dry-Run starten" }).click();
await expect(page.getByText(`root@${name}:/root/.ssh/authorized_keys`)).toBeVisible({ timeout: 30_000 });
await page.getByRole("button", { name: /Änderung anwenden/ }).click();
await expect(page.getByText("Ausgerollt.")).toBeVisible({ timeout: 60_000 });
expect(aufZielB("grep -c laptop /root/.ssh/authorized_keys").trim()).toBe("1");
await page.getByRole("button", { name: "Jetzt abgleichen" }).click();
await expect(page.getByText("Keine offenen Abweichungen.")).toBeVisible({ timeout: 30_000 });
await page.getByRole("button", { name: "Host archivieren" }).click();
await page.getByRole("dialog").getByRole("button", { name: "Archivieren", exact: true }).click();
});
+169
View File
@@ -0,0 +1,169 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import { schluesselpaarErzeugen } from "@/lib/ssh/openssh";
import { zieleVonPrivatVerwendungen, zieleVonVerwendungen } from "@/server/services/soll";
import { privateVerwendungAnlegen, publicVerwendungAnlegen } from "@/server/services/verwendungen";
import { keyUebernehmen } from "@/server/services/uebernahme";
import { deploymentKeySicherstellen, schluesselpaarVersiegeltErzeugen } from "@/worker/geheimnisse";
import { datenbankLeeren } from "../hilfen/db";
import { eingerichteterHost, masterKeyAnlegen, vorgangLaufen } from "./hilfen/host";
import { befehlAlsRoot, zielA, zielBereit } from "./hilfen/ziele";
/** Abgleich und Drift-Erkennung gegen ein echtes sshd (#021). */
const AKTEUR = { id: "test", name: "Test" };
let aufraeumen: () => void;
beforeAll(async () => {
await zielBereit(zielA);
aufraeumen = masterKeyAnlegen();
});
afterAll(() => aufraeumen());
beforeEach(async () => {
await datenbankLeeren();
await deploymentKeySicherstellen();
await befehlAlsRoot(
zielA,
"userdel -r sshkv 2>/dev/null; rm -f /etc/sudoers.d/sshkv; rm -rf /root/.ssh; true",
);
});
async function ausgerollterHost() {
const host = await eingerichteterHost(zielA, "ziel-a");
const paar = await schluesselpaarErzeugen("ED25519", { kommentar: "sshkv:abgleich" });
const schluessel = await prisma.schluessel.create({
data: {
name: "Abgleich",
slug: "abgleich",
typ: "ED25519",
kategorie: "DIENST",
publicKey: paar.publicKeyZeile,
fingerprint: paar.fingerprint,
kommentar: "sshkv:abgleich",
},
});
await publicVerwendungAnlegen(
{ schluesselId: schluessel.id, hostId: host.id, zielbenutzer: "root" },
AKTEUR,
);
const trockenlauf = await vorgangLaufen("TROCKENLAUF", {
eingabe: { ziele: await zieleVonVerwendungen({ schluesselId: schluessel.id }) },
});
await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: trockenlauf.id } });
return { host, schluessel };
}
const abgleichen = (hostId: string) => vorgangLaufen("ABGLEICH", { hostId });
// Postgres sortiert Enums nach Deklaration; fuer lesbare Erwartungen alphabetisch in JS.
const offen = async (hostId: string) =>
(await prisma.abweichung.findMany({ where: { hostId, behobenAm: null } })).sort((a, b) =>
a.art.localeCompare(b.art),
);
describe("Abgleich", () => {
it("ist nach dem Ausrollen synchron und haelt den Ist-Zustand je Benutzer fest", async () => {
const { host } = await ausgerollterHost();
const vorgang = await abgleichen(host.id);
expect(vorgang.fehler).toBeNull();
expect(await offen(host.id)).toEqual([]);
const aktuell = await prisma.host.findUniqueOrThrow({ where: { id: host.id } });
expect(aktuell.driftZustand).toBe("SYNCHRON");
expect(aktuell.letzterAbgleichAm).not.toBeNull();
const beobachtet = await prisma.beobachtung.findMany({
where: { hostId: host.id },
orderBy: { zielbenutzer: "asc" },
});
expect(beobachtet.map((b) => b.zielbenutzer)).toEqual(["root", "sshkv"]);
expect(JSON.stringify(beobachtet[0]?.eintraege)).toContain('"herkunft":"verwaltet"');
});
it("findet fremde Keys, fehlende Keys und falsche Rechte – und schliesst behobene Befunde", async () => {
const { host } = await ausgerollterHost();
const fremd = await schluesselpaarErzeugen("ED25519", { kommentar: "fremd@laptop" });
await befehlAlsRoot(
zielA,
`echo '${fremd.publicKeyZeile}' >> /root/.ssh/authorized_keys && sed -i '/sshkv:abgleich/d' /root/.ssh/authorized_keys && chmod 777 /root/.ssh`,
);
await abgleichen(host.id);
const arten = (await offen(host.id)).map((a) => a.art);
expect(arten).toEqual(["FEHLENDER_SCHLUESSEL", "RECHTE", "UNBEKANNTER_SCHLUESSEL"]);
expect((await prisma.host.findUniqueOrThrow({ where: { id: host.id } })).driftZustand).toBe("DRIFT");
expect((await prisma.publicVerwendung.findFirstOrThrow()).zustand).toBe("ABWEICHUNG");
// Zweiter Lauf ohne Aenderung: keine doppelten Befunde.
await abgleichen(host.id);
expect(await offen(host.id)).toHaveLength(3);
// Von Hand bereinigen: fremden Key raus, Rechte zurueck – der fehlende Key bleibt Befund.
await befehlAlsRoot(zielA, "sed -i '/fremd@laptop/d' /root/.ssh/authorized_keys && chmod 700 /root/.ssh");
await abgleichen(host.id);
expect((await offen(host.id)).map((a) => a.art)).toEqual(["FEHLENDER_SCHLUESSEL"]);
});
it("bemerkt einen veraenderten Private Key", async () => {
const host = await eingerichteterHost(zielA, "ziel-a");
const neu = await schluesselpaarVersiegeltErzeugen("ED25519", "sshkv:privat");
const schluessel = await prisma.schluessel.create({
data: {
id: neu.id,
name: "Privat",
slug: "privat",
typ: "ED25519",
kategorie: "DIENST",
publicKey: neu.publicKeyZeile,
fingerprint: neu.fingerprint,
kommentar: "sshkv:privat",
...neu.versiegelt,
},
});
await privateVerwendungAnlegen(
{ schluesselId: schluessel.id, hostId: host.id, zielbenutzer: "root", pfad: "~/.ssh/id_privat" },
AKTEUR,
);
const trockenlauf = await vorgangLaufen("TROCKENLAUF", {
eingabe: { ziele: await zieleVonPrivatVerwendungen({ schluesselId: schluessel.id }) },
});
await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: trockenlauf.id } });
await abgleichen(host.id);
expect(await offen(host.id)).toEqual([]);
await befehlAlsRoot(zielA, "echo kaputt > /root/.ssh/id_privat");
await abgleichen(host.id);
expect((await offen(host.id)).map((a) => a.art)).toEqual(["PRIVATE_KEY_FEHLT"]);
expect((await prisma.privateVerwendung.findFirstOrThrow()).zustand).toBe("ABWEICHUNG");
});
it("uebernimmt einen fremden Key in den verwalteten Block (#022)", async () => {
const { host } = await ausgerollterHost();
const fremd = await schluesselpaarErzeugen("ED25519", { kommentar: "anna@laptop" });
await befehlAlsRoot(zielA, `sed -i '1i no-pty ${fremd.publicKeyZeile}' /root/.ssh/authorized_keys`);
await abgleichen(host.id);
expect((await offen(host.id)).map((a) => a.art)).toEqual(["UNBEKANNTER_SCHLUESSEL"]);
const admin = await prisma.benutzer.create({ data: { id: "test", name: "Test", rolle: "ADMIN" } });
const { schluesselId } = await keyUebernehmen(
{
hostId: host.id,
zielbenutzer: "root",
fingerprint: fremd.fingerprint,
name: "Laptop Anna",
kategorie: "PERSON",
personId: null,
verwalten: true,
},
{ id: admin.id, name: admin.name },
);
const trockenlauf = await vorgangLaufen("TROCKENLAUF", {
eingabe: { ziele: await zieleVonVerwendungen({ schluesselId }) },
});
await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: trockenlauf.id } });
const datei = (await befehlAlsRoot(zielA, "cat /root/.ssh/authorized_keys")).stdout;
expect(datei.startsWith("# BEGIN SSHKV MANAGED")).toBe(true);
expect(datei.match(/anna@laptop/g)).toHaveLength(1);
expect(datei).toContain(`no-pty ${fremd.publicKeyZeile}`);
await abgleichen(host.id);
expect(await offen(host.id)).toEqual([]);
});
});
+2 -1
View File
@@ -28,7 +28,8 @@ export async function vorgangLaufen(
| "AUSROLLEN"
| "VERBINDUNGSTEST"
| "ZIELKONTO_ANLEGEN"
| "ZIELKONTO_ENTFERNEN",
| "ZIELKONTO_ENTFERNEN"
| "ABGLEICH",
daten: {
hostId?: string;
eingabe?: object;
+81
View File
@@ -0,0 +1,81 @@
import { generateKeyPairSync } from "node:crypto";
import { describe, expect, it } from "vitest";
import { authorizedKeysAbgleichen } from "@/lib/ssh/abgleich";
import { blockSetzen, type SollEintrag } from "@/lib/ssh/authorized-keys";
import { fingerprintSha256 } from "@/lib/ssh/openssh";
/** Abgleich einer authorized_keys mit dem Soll (#021). */
function key(kommentar: string) {
const { publicKey } = generateKeyPairSync("ed25519");
const roh = publicKey.export({ format: "der", type: "spki" }).subarray(-32);
const typ = Buffer.from("ssh-ed25519");
const blob = Buffer.concat([Buffer.from([0, 0, 0, typ.length]), typ, Buffer.from([0, 0, 0, 32]), roh]);
return {
publicKey: `ssh-ed25519 ${blob.toString("base64")} ${kommentar}`,
fingerprint: fingerprintSha256(blob),
};
}
const DEPLOY = key("deploy");
const BACKUP = key("backup");
const FREMD = key("hacker@irgendwo");
const BEKANNT = key("bekannt");
const soll: SollEintrag[] = [DEPLOY, { ...BACKUP, optionen: 'from="10.0.0.1"' }];
const bekannt = new Set([DEPLOY.fingerprint, BACKUP.fingerprint, BEKANNT.fingerprint]);
describe("authorized_keys abgleichen", () => {
it("ist ohne Befund, wenn der Block dem Soll entspricht", () => {
const inhalt = blockSetzen(`${BEKANNT.publicKey}\n`, soll).inhalt;
const ergebnis = authorizedKeysAbgleichen(inhalt, soll, bekannt, {
sshVerzeichnis: "700",
datei: "600",
eigentuemerPasst: true,
});
expect(ergebnis.befunde).toEqual([]);
expect(ergebnis.vorhanden).toEqual([DEPLOY.fingerprint, BACKUP.fingerprint]);
expect(ergebnis.eintraege.map((e) => e.herkunft)).toEqual(["bekannt", "verwaltet", "verwaltet"]);
});
it("findet fehlende, geaenderte und zusaetzliche Keys im Block", () => {
const inhalt = blockSetzen("", [DEPLOY, BACKUP, FREMD]).inhalt; // BACKUP ohne Optionen, FREMD zu viel
const arten = authorizedKeysAbgleichen(inhalt, soll, bekannt).befunde.map((b) => [b.art, b.fingerprint]);
expect(arten).toEqual([
["ZUSAETZLICH_IM_BLOCK", BACKUP.fingerprint],
["ZUSAETZLICH_IM_BLOCK", FREMD.fingerprint],
]);
const leer = authorizedKeysAbgleichen(null, soll, bekannt).befunde.map((b) => b.art);
expect(leer).toEqual(["FEHLENDER_SCHLUESSEL", "FEHLENDER_SCHLUESSEL"]);
});
it("meldet nur unbekannte Keys ausserhalb des Blocks", () => {
const inhalt = blockSetzen(`${FREMD.publicKey}\n${FREMD.publicKey}\n${BEKANNT.publicKey}\n`, soll).inhalt;
const befunde = authorizedKeysAbgleichen(inhalt, soll, bekannt).befunde;
expect(befunde).toHaveLength(1);
expect(befunde[0]).toMatchObject({ art: "UNBEKANNTER_SCHLUESSEL", fingerprint: FREMD.fingerprint });
expect(befunde[0]?.text).toContain("hacker@irgendwo");
});
it("prueft Rechte und Eigentuemer", () => {
const befunde = authorizedKeysAbgleichen("", [], bekannt, {
sshVerzeichnis: "777",
datei: "664",
eigentuemerPasst: false,
}).befunde;
expect(befunde.map((b) => b.art)).toEqual(["RECHTE", "RECHTE", "RECHTE"]);
expect(
authorizedKeysAbgleichen("", [], bekannt, {
sshVerzeichnis: "755",
datei: "644",
eigentuemerPasst: true,
}).befunde,
).toEqual([]);
});
it("meldet einen beschaedigten Block", () => {
expect(authorizedKeysAbgleichen("# BEGIN SSHKV MANAGED\n", [], bekannt).befunde[0]?.art).toBe(
"DATEI_GEAENDERT",
);
});
});
+11
View File
@@ -209,3 +209,14 @@ describe("Diff", () => {
expect(kurz[0]?.text).toMatch(/8 unveränderte Zeile/);
});
});
describe("uebernommene Keys (#022)", () => {
it("entfernt nur ausdruecklich genannte Zeilen ausserhalb des Blocks", () => {
const vorher = `# Kommentar\n${FREMD.zeile}\n${BACKUP.zeile}\n`;
const ergebnis = blockSetzen(vorher, soll(FREMD), [], [FREMD.fingerprint]);
expect(ergebnis.inhalt).toBe(
`# Kommentar\n${BACKUP.zeile}\n# BEGIN SSHKV MANAGED – nicht manuell bearbeiten\n${FREMD.zeile}\n# END SSHKV MANAGED\n`,
);
expect(ergebnis.hinweise).toEqual([]);
});
});