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
|
# #021 Abgleich und Drift-Erkennung
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.4.0
|
**Meilenstein:** v0.4.0
|
||||||
**Labels:** backend
|
**Labels:** backend
|
||||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
**Geschätzt:** 3 h **Tatsächlich:** 0 h 45 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Periodischer und manueller Abgleich, Befunde je Host/Benutzer.
|
Periodischer und manueller Abgleich, Befunde je Host/Benutzer.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Zeitplan konfigurierbar
|
- [x] Zeitplan konfigurierbar
|
||||||
- [ ] Befundarten
|
- [x] Befundarten
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Integrationstest
|
- [x] Integrationstest
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#016
|
#016
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## 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
|
# #022 Import bestehender Schlüssel
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.4.0
|
**Meilenstein:** v0.4.0
|
||||||
**Labels:** backend, ui
|
**Labels:** backend, ui
|
||||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
**Geschätzt:** 2 h **Tatsächlich:** 0 h 20 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Unbekannte Schlüssel eines Hosts übernehmen und zuordnen.
|
Unbekannte Schlüssel eines Hosts übernehmen und zuordnen.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] wird zu Beginn der Phase verfeinert
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] wird zu Beginn der Phase verfeinert
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#021
|
#021
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## 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
|
zielbenutzer String
|
||||||
/// authorized_keys-Optionen, z. B. restrict,from="10.0.0.12".
|
/// authorized_keys-Optionen, z. B. restrict,from="10.0.0.12".
|
||||||
optionen String?
|
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)
|
zustand VerwendungsZustand @default(GEPLANT)
|
||||||
zuletztAngewendetAm DateTime?
|
zuletztAngewendetAm DateTime?
|
||||||
angelegtVonId String?
|
angelegtVonId String?
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
|||||||
import { hostDetail, type HostDetail } from "@/server/queries/hosts";
|
import { hostDetail, type HostDetail } from "@/server/queries/hosts";
|
||||||
import { BootstrapAktionen } from "./BootstrapAktionen";
|
import { BootstrapAktionen } from "./BootstrapAktionen";
|
||||||
import { sshdHinweise } from "@/lib/sshd-hinweise";
|
import { sshdHinweise } from "@/lib/sshd-hinweise";
|
||||||
|
import { AbgleichKarten } from "@/components/abgleich/AbgleichKarten";
|
||||||
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
||||||
|
import { istZustand } from "@/server/queries/abgleich";
|
||||||
import { VerwendungsZustand } from "@/components/ausrollen/VerwendungsZustand";
|
import { VerwendungsZustand } from "@/components/ausrollen/VerwendungsZustand";
|
||||||
import { verwendungenAufHost } from "@/server/queries/ziele";
|
import { verwendungenAufHost } from "@/server/queries/ziele";
|
||||||
import { HostKeyAktionen } from "./HostKeyAktionen";
|
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 darfEntscheiden = darf(benutzer.rolle, "hostKeyEntscheiden") && !archiviert;
|
||||||
const darfAusrollen = darf(benutzer.rolle, "publicKeyAusrollen") && !archiviert;
|
const darfAusrollen = darf(benutzer.rolle, "publicKeyAusrollen") && !archiviert;
|
||||||
const verwendungen = await verwendungenAufHost(host.id);
|
const verwendungen = await verwendungenAufHost(host.id);
|
||||||
|
const ist = await istZustand(host.id);
|
||||||
const jetzt = new Date();
|
const jetzt = new Date();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -295,6 +298,16 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri
|
|||||||
)}
|
)}
|
||||||
</Karte>
|
</Karte>
|
||||||
|
|
||||||
|
{host.bootstrapAm && (
|
||||||
|
<AbgleichKarten
|
||||||
|
hostId={host.id}
|
||||||
|
zustand={ist}
|
||||||
|
darfAbgleichen={verwalten}
|
||||||
|
darfUebernehmen={darf(benutzer.rolle, "schluesselVerwalten") && !archiviert}
|
||||||
|
darfVerwalten={darfAusrollen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{verwalten && (
|
{verwalten && (
|
||||||
<Karte titel="Archivieren" untertitel="Host stilllegen, ohne die Historie zu verlieren">
|
<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">
|
<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 type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
letzterAbgleich,
|
letzterAbgleich,
|
||||||
type AktionsArt,
|
type AktionsArt,
|
||||||
} from "@/beispiel/uebersicht";
|
} from "@/beispiel/uebersicht";
|
||||||
|
import { AlleAbgleichenKnopf } from "@/components/abgleich/AlleAbgleichenKnopf";
|
||||||
import { Badge } from "@/components/ui/Badge";
|
import { Badge } from "@/components/ui/Badge";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Karte } from "@/components/ui/Karte";
|
import { Karte } from "@/components/ui/Karte";
|
||||||
@@ -52,7 +53,7 @@ export default async function Uebersicht() {
|
|||||||
}
|
}
|
||||||
aktionen={
|
aktionen={
|
||||||
<>
|
<>
|
||||||
<Button icon={<RefreshCw className="size-4" />}>Jetzt abgleichen</Button>
|
<AlleAbgleichenKnopf />
|
||||||
<Button href="/assistent" icon={<Route className="size-4" />}>
|
<Button href="/assistent" icon={<Route className="size-4" />}>
|
||||||
Host-zu-Host-Assistent
|
Host-zu-Host-Assistent
|
||||||
</Button>
|
</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,
|
inhalt: string,
|
||||||
soll: readonly SollEintrag[],
|
soll: readonly SollEintrag[],
|
||||||
pflicht: readonly string[] = [],
|
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 {
|
): BlockErgebnis {
|
||||||
const datei = authorizedKeysLesen(inhalt);
|
const datei = authorizedKeysLesen(inhalt);
|
||||||
if (datei.block.art === "beschaedigt") {
|
if (datei.block.art === "beschaedigt") {
|
||||||
@@ -227,11 +233,18 @@ export function blockSetzen(
|
|||||||
|
|
||||||
let rohNeu: string[];
|
let rohNeu: string[];
|
||||||
let endetMitZeilenende = datei.endetMitZeilenende;
|
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 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") {
|
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 {
|
} else {
|
||||||
rohNeu = [...roh, ...neuerBlock];
|
rohNeu = [...rohOhne(0), ...neuerBlock];
|
||||||
// Ein neuer Block beginnt auf einer eigenen Zeile; die bisher letzte Zeile bekommt dafuer
|
// Ein neuer Block beginnt auf einer eigenen Zeile; die bisher letzte Zeile bekommt dafuer
|
||||||
// ihre fehlende Zeilenschaltung – die einzige Aenderung ausserhalb des Blocks.
|
// ihre fehlende Zeilenschaltung – die einzige Aenderung ausserhalb des Blocks.
|
||||||
endetMitZeilenende = true;
|
endetMitZeilenende = true;
|
||||||
@@ -241,7 +254,9 @@ export function blockSetzen(
|
|||||||
const hinweise: string[] = [];
|
const hinweise: string[] = [];
|
||||||
const ausserhalb = datei.zeilen.filter((z): z is SchluesselZeile => z.art === "schluessel" && !z.imBlock);
|
const ausserhalb = datei.zeilen.filter((z): z is SchluesselZeile => z.art === "schluessel" && !z.imBlock);
|
||||||
for (const eintrag of eintraege) {
|
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) {
|
if (doppelt) {
|
||||||
hinweise.push(
|
hinweise.push(
|
||||||
`${eintrag.fingerprint} steht zusätzlich außerhalb des Blocks${doppelt.optionen ? "" : " – dort ohne Einschränkungen"}.`,
|
`${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[];
|
readonly eintraege: SollEintrag[];
|
||||||
/** Fingerprints, die nie aus dem Block fallen duerfen (Deployment-Key). */
|
/** Fingerprints, die nie aus dem Block fallen duerfen (Deployment-Key). */
|
||||||
readonly pflicht: string[];
|
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. */
|
/** Verwendungen, die in diesem Soll stecken oder entfernt werden – fuer den Zustand danach. */
|
||||||
readonly verwendungIds: { readonly drin: string[]; readonly raus: string[] };
|
readonly verwendungIds: { readonly drin: string[]; readonly raus: string[] };
|
||||||
};
|
};
|
||||||
@@ -58,6 +60,7 @@ export async function sollFuer(hostId: string, zielbenutzer: string): Promise<So
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
optionen: true,
|
optionen: true,
|
||||||
|
ausserhalbEntfernen: true,
|
||||||
zustand: true,
|
zustand: true,
|
||||||
angelegtAm: true,
|
angelegtAm: true,
|
||||||
schluessel: { select: { publicKey: true, fingerprint: true, status: 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 drin: string[] = [];
|
||||||
const raus: string[] = [];
|
const raus: string[] = [];
|
||||||
|
const ausserhalbEntfernen: string[] = [];
|
||||||
for (const verwendung of verwendungen) {
|
for (const verwendung of verwendungen) {
|
||||||
const aktiv = verwendung.schluessel.status === "AKTIV" || verwendung.schluessel.status === "ROTIERT";
|
const aktiv = verwendung.schluessel.status === "AKTIV" || verwendung.schluessel.status === "ROTIERT";
|
||||||
if ((IM_SOLL as readonly string[]).includes(verwendung.zustand) && aktiv) {
|
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,
|
optionen: verwendung.optionen,
|
||||||
});
|
});
|
||||||
drin.push(verwendung.id);
|
drin.push(verwendung.id);
|
||||||
|
if (verwendung.ausserhalbEntfernen) ausserhalbEntfernen.push(verwendung.schluessel.fingerprint);
|
||||||
} else {
|
} else {
|
||||||
raus.push(verwendung.id);
|
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). */
|
/** 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));
|
return privatPlanZiel(host, benutzer, await privatPlanen(verbindung, host, benutzer));
|
||||||
const stand = await authorizedKeysHolen(verbindung, host, benutzer);
|
const stand = await authorizedKeysHolen(verbindung, host, benutzer);
|
||||||
const soll = await sollFuer(host.id, 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 {
|
return {
|
||||||
art: "public",
|
art: "public",
|
||||||
hostId: host.id,
|
hostId: host.id,
|
||||||
@@ -249,7 +249,7 @@ export const ausrollenHandler: Handler = async ({ vorgang }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const soll = await sollFuer(host.id, 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);
|
||||||
if (hashVon(ergebnis.inhalt) !== ziel.nachher) {
|
if (hashVon(ergebnis.inhalt) !== ziel.nachher) {
|
||||||
throw new VorgangsFehler(
|
throw new VorgangsFehler(
|
||||||
`Die Zuweisungen für ${benutzer}@${host.name} haben sich seit dem Trockenlauf geändert. Bitte neuen Trockenlauf starten.`,
|
`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 type { HandlerTabelle } from "@/worker/ausfuehren";
|
||||||
|
import { abgleichHandler } from "@/worker/ablaeufe/abgleich";
|
||||||
import { ausrollenHandler, trockenlaufHandler } from "@/worker/ablaeufe/ausrollen";
|
import { ausrollenHandler, trockenlaufHandler } from "@/worker/ablaeufe/ausrollen";
|
||||||
import { bootstrapHandler } from "@/worker/ablaeufe/bootstrap";
|
import { bootstrapHandler } from "@/worker/ablaeufe/bootstrap";
|
||||||
import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey";
|
import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey";
|
||||||
@@ -8,7 +9,7 @@ import { verbindungstestHandler } from "@/worker/ablaeufe/verbindungstest";
|
|||||||
import { zielkontoAnlegenHandler, zielkontoEntfernenHandler } from "@/worker/ablaeufe/zielkonto";
|
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 = {
|
export const HANDLER: HandlerTabelle = {
|
||||||
HOSTKEY_ABRUFEN: hostKeyAbrufenHandler,
|
HOSTKEY_ABRUFEN: hostKeyAbrufenHandler,
|
||||||
@@ -21,6 +22,7 @@ export const HANDLER: HandlerTabelle = {
|
|||||||
PRIVATE_KEY_ABHOLEN: privateKeyAbholenHandler,
|
PRIVATE_KEY_ABHOLEN: privateKeyAbholenHandler,
|
||||||
PRIVATE_KEY_LOESCHEN: privateKeyLoeschenHandler,
|
PRIVATE_KEY_LOESCHEN: privateKeyLoeschenHandler,
|
||||||
VERBINDUNGSTEST: verbindungstestHandler,
|
VERBINDUNGSTEST: verbindungstestHandler,
|
||||||
|
ABGLEICH: abgleichHandler,
|
||||||
ZIELKONTO_ANLEGEN: zielkontoAnlegenHandler,
|
ZIELKONTO_ANLEGEN: zielkontoAnlegenHandler,
|
||||||
ZIELKONTO_ENTFERNEN: zielkontoEntfernenHandler,
|
ZIELKONTO_ENTFERNEN: zielkontoEntfernenHandler,
|
||||||
};
|
};
|
||||||
|
|||||||
+18
-2
@@ -1,7 +1,13 @@
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import type { Job } from "pg-boss";
|
import type { Job } from "pg-boss";
|
||||||
import { VERSION } from "@/lib/version";
|
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 { vorgangAusfuehren } from "@/worker/ausfuehren";
|
||||||
import { abholungenAufraeumen } from "@/worker/ablaeufe/privatekey";
|
import { abholungenAufraeumen } from "@/worker/ablaeufe/privatekey";
|
||||||
import { deploymentKeySicherstellen } from "@/worker/geheimnisse";
|
import { deploymentKeySicherstellen } from "@/worker/geheimnisse";
|
||||||
@@ -45,7 +51,17 @@ async function starten(): Promise<void> {
|
|||||||
await aufraeumen();
|
await aufraeumen();
|
||||||
const takt = setInterval(() => void aufraeumen(), 30_000);
|
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) => {
|
const beenden = async (signal: string) => {
|
||||||
console.log(`${signal} empfangen, beende laufende Vorgänge …`);
|
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"
|
| "AUSROLLEN"
|
||||||
| "VERBINDUNGSTEST"
|
| "VERBINDUNGSTEST"
|
||||||
| "ZIELKONTO_ANLEGEN"
|
| "ZIELKONTO_ANLEGEN"
|
||||||
| "ZIELKONTO_ENTFERNEN",
|
| "ZIELKONTO_ENTFERNEN"
|
||||||
|
| "ABGLEICH",
|
||||||
daten: {
|
daten: {
|
||||||
hostId?: string;
|
hostId?: string;
|
||||||
eingabe?: object;
|
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/);
|
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