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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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?
|
||||
|
||||
@@ -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 …`);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,8 @@ export async function vorgangLaufen(
|
||||
| "AUSROLLEN"
|
||||
| "VERBINDUNGSTEST"
|
||||
| "ZIELKONTO_ANLEGEN"
|
||||
| "ZIELKONTO_ENTFERNEN",
|
||||
| "ZIELKONTO_ENTFERNEN"
|
||||
| "ABGLEICH",
|
||||
daten: {
|
||||
hostId?: string;
|
||||
eingabe?: object;
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user