Merge branch 'phase/021-abgleich'
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 182 KiB |
@@ -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,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();
|
||||
});
|
||||
@@ -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