Merge branch 'phase/031-deploymentkey-rotation'

This commit is contained in:
2026-10-12 17:27:25 +02:00
19 changed files with 892 additions and 20 deletions
+1
View File
@@ -8,6 +8,7 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht] ## [Unveröffentlicht]
### Hinzugefügt ### Hinzugefügt
- Geführte Rotation des Deployment-Keys in fünf Schritten (erzeugen, verteilen, prüfen, umstellen, aufräumen) – kein Host steht dabei ohne funktionierenden Key da; nicht erreichbare Hosts lassen sich bewusst übergehen (#031)
### Geändert ### Geändert
+8
View File
@@ -170,6 +170,14 @@ auswählen (nur Admin).
![Widerruf](bilder/widerruf-desktop.png) ![Widerruf](bilder/widerruf-desktop.png)
**Deployment-Key rotieren** (Einstellungen → Instanz, nur Admin): fünf Schritte – neuen Key
**erzeugen**, per Dry-Run auf alle Hosts **verteilen** (er steht dann neben dem alten), die Anmeldung
mit dem neuen Key **prüfen**, **umstellen** und zuletzt den alten per Dry-Run **aufräumen**. Scheitert
die Prüfung auf einem Host, lässt er sich bewusst übergehen; sshkv erreicht ihn danach nur noch nach
einem neuen Bootstrap.
![Deployment-Key rotieren](bilder/deployment-key-desktop.png)
## 8. Personen, Self-Service und Zuweisungsmatrix ## 8. Personen, Self-Service und Zuweisungsmatrix
- **Personen:** Admins, Mitarbeiter, Dienstleister mit ihren Schlüsseln. Private Keys von Personen - **Personen:** Admins, Mitarbeiter, Dienstleister mit ihren Schlüsseln. Private Keys von Personen
Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

+6 -2
View File
@@ -67,8 +67,12 @@ Private Key steht. Bootstrap-Passwörter werden nie gespeichert.
5. **Protokoll auswerten:** Syslog-Kopie (unveränderlich außerhalb von sshkv) mit dem Protokoll 5. **Protokoll auswerten:** Syslog-Kopie (unveränderlich außerhalb von sshkv) mit dem Protokoll
vergleichen; `protokoll_kette_pruefen()` zeigt manipulierte Zeilen. vergleichen; `protokoll_kette_pruefen()` zeigt manipulierte Zeilen.
> Eine eingebaute **Rotation des Deployment-Keys** gibt es noch nicht (siehe `docs/issues`). Bis dahin > **Rotation des Deployment-Keys** (Einstellungen → Instanz → „Deployment-Key rotieren“, nur Admin mit
> ist Schritt 2 Handarbeit. > frischer Bestätigung): regelmäßig, oder wenn nur der Key abgeflossen sein kann – etwa ein verlorenes
> Backup samt Master-Key. Der neue Key kommt erst neben den alten, wird auf jedem Host geprüft und löst
> den alten dann ab; zuletzt wird der alte aus allen Blöcken entfernt und sein Private Key verworfen.
> Ist sshkv **selbst** kompromittiert, hilft das nicht – der Instanz ist dann nicht mehr zu trauen,
> Schritt 2 bleibt Handarbeit.
## Betriebsregeln ## Betriebsregeln
+15 -10
View File
@@ -1,25 +1,30 @@
# #031 Rotation des Deployment-Keys # #031 Rotation des Deployment-Keys
**Status:** offen **Status:** erledigt
**Meilenstein:** v1.0.0 **Meilenstein:** v1.0.0
**Labels:** sicherheit, backend **Labels:** sicherheit, backend
**Geschätzt:** 3 h **Tatsächlich:** – **Geschätzt:** 3 h **Tatsächlich:** 1 h 10 min
## Ziel ## Ziel
Den Deployment-Key der Instanz geführt tauschen – regelmäßig oder im Notfall (sshkv kompromittiert, siehe `docs/betrieb/sicherheit.md`). Den Deployment-Key der Instanz geführt tauschen – regelmäßig oder im Notfall (sshkv kompromittiert, siehe `docs/betrieb/sicherheit.md`).
## Aufgaben ## Aufgaben
- [ ] Neuen Deployment-Key erzeugen, ohne den alten abzulösen (zweiter Instanz-Key im Übergang) - [x] Neuen Deployment-Key erzeugen, ohne den alten abzulösen (zweiter Instanz-Key im Übergang)
- [ ] Auf jedem eingerichteten Host den neuen Key neben den alten in den verwalteten Block des Anmeldebenutzers schreiben (Dry-Run, Ausrollen) - [x] Auf jedem eingerichteten Host den neuen Key neben den alten in den verwalteten Block des Anmeldebenutzers schreiben (Dry-Run, Ausrollen)
- [ ] Verbindung mit dem neuen Key je Host prüfen - [x] Verbindung mit dem neuen Key je Host prüfen
- [ ] Erst wenn alle Hosts den neuen Key akzeptieren: alten aus allen Blöcken entfernen, alten Key verwerfen - [x] Erst wenn alle Hosts den neuen Key akzeptieren: alten aus allen Blöcken entfernen, alten Key verwerfen
- [ ] Nicht erreichbare Hosts sichtbar machen; Abschluss erst, wenn alle umgestellt sind (oder bewusst übergangen) - [x] Nicht erreichbare Hosts sichtbar machen; Abschluss erst, wenn alle umgestellt sind (oder bewusst übergangen)
- [ ] Nur Admin mit frischer Bestätigung, alles protokolliert - [x] Nur Admin mit frischer Bestätigung, alles protokolliert
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Integrationstest: nach der Rotation meldet sich sshkv nur noch mit dem neuen Key an, der alte steht auf keinem Host mehr - [x] Integrationstest: nach der Rotation meldet sich sshkv nur noch mit dem neuen Key an, der alte steht auf keinem Host mehr
- [ ] Aussperrschutz: zu keinem Zeitpunkt steht ein Host ohne funktionierenden Deployment-Key da - [x] Aussperrschutz: zu keinem Zeitpunkt steht ein Host ohne funktionierenden Deployment-Key da
## Abhängigkeiten ## Abhängigkeiten
#016, #021 #016, #021
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- Der Nachfolger ist ein eigener Schlüssel (`kategorie DEPLOYMENT`, `vorgaengerId` = alter, noch nicht `instanzStandard`). `sollFuer()` nimmt ihn für alle Hosts mit dem Instanz-Key zusätzlich in Block **und** Pflichtliste – der normale Dry-Run verteilt ihn also, und der Aussperrschutz schützt beide.
- Hosts mit eigenem oder Gruppen-Deployment-Key nehmen nicht teil.
- Umstellen ist eine Transaktion (alter ROTIERT, neuer Standard) und verlangt eine Prüfung **nach** dem Erzeugen, in der jeder Host den neuen Key angenommen hat – oder bewusst übergangen wird (protokolliert).
- Abschließen prüft je Host, dass der alte Fingerprint aus dem Block verschwunden ist; erst dann wird sein Private Key verworfen. Unerreichbare Hosts blockieren den Abschluss nicht (sshkv benutzt den alten Key ohnehin nicht mehr), werden aber im Ergebnis genannt.
- Tests: Integrationstest gegen beide Testziele (DIENSTBENUTZER und ROOT, alte Anmeldung scheitert danach), E2E im Browser.
+165
View File
@@ -0,0 +1,165 @@
import { CheckCheck, KeyRound, ShieldCheck } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
import { Umstellen } from "@/components/deploymentkey/Umstellen";
import { Badge } from "@/components/ui/Badge";
import { Breadcrumb } from "@/components/ui/Breadcrumb";
import { Karte } from "@/components/ui/Karte";
import { MonoKopieren } from "@/components/ui/MonoKopieren";
import { Schrittleiste } from "@/components/ui/Schrittleiste";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
import { rotationsSchrittAktion } from "@/server/actions/deploymentkey";
import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { rotationsStand } from "@/server/services/deploymentkey";
export const metadata: Metadata = { title: "Deployment-Key rotieren" };
const SCHRITTE = ["Erzeugen", "Verteilen", "Prüfen", "Umstellen", "Aufräumen"] as const;
/**
* Rotation des Instanz-Deployment-Keys (#031). Der neue Key kommt erst neben den alten,
* wird geprueft und erst dann zum Standard; der alte verschwindet zuletzt.
*/
export default async function DeploymentKeySeite() {
const { benutzer } = await sitzungErforderlich("deploymentKeyVerwalten", "/deployment-key");
const stand = await rotationsStand();
const fehlgeschlagen = (stand.pruefung ?? []).filter((p) => !p.ok);
const aktuell =
stand.phase === "bereit"
? 0
: stand.phase === "aufraeumen"
? 4
: !stand.pruefung
? 1
: fehlgeschlagen.length
? 2
: 3;
return (
<div className="flex flex-col gap-6 sm:gap-7">
<Seitenkopf
oben={
<Breadcrumb
pfad={[{ titel: "Einstellungen", href: "/einstellungen" }, { titel: "Deployment-Key" }]}
/>
}
titel="Deployment-Key rotieren"
untertitel="Ohne Unterbrechung: der neue Key steht erst neben dem alten, wird geprüft und löst ihn dann ab."
/>
<Schrittleiste schritte={SCHRITTE} aktuell={aktuell} beschriftung="Schritte der Rotation" />
{!bestaetigungFrisch(benutzer.bestaetigtAm) && (
<p className="text-accent text-[13.5px]">
Jeder Schritt verlangt eine frische Bestätigung Ihrer Anmeldung.{" "}
<Link href="/bestaetigen?ziel=%2Fdeployment-key" className="underline">
Jetzt bestätigen
</Link>
</p>
)}
<Karte titel="Aktueller Deployment-Key" untertitel={`Benutzt auf ${stand.hosts.length} Host(s)`}>
{stand.aktuell ? (
<MonoKopieren
wert={stand.aktuell.fingerprint}
beschriftung="Fingerprint des aktuellen Keys"
umbrechen
/>
) : (
<p className="text-accent text-[14px]">Noch kein Deployment-Key – läuft der Worker?</p>
)}
{stand.nachfolger && (
<div className="mt-4">
<p className="text-muted mb-1 text-[13px]">Neuer Key (noch nicht Standard)</p>
<MonoKopieren
wert={stand.nachfolger.fingerprint}
beschriftung="Fingerprint des neuen Keys"
umbrechen
/>
</div>
)}
</Karte>
{stand.phase === "bereit" && stand.aktuell && (
<Karte
titel="1 · Neuen Key erzeugen"
untertitel="Der Worker erzeugt ein neues ed25519-Paar. Noch ändert sich auf keinem Host etwas."
>
<VorgangsKnopf
aktion={rotationsSchrittAktion.bind(null, "erzeugen")}
beschriftung="Neuen Deployment-Key erzeugen"
variante="primaer"
icon={<KeyRound className="size-4" />}
/>
</Karte>
)}
{stand.phase === "verteilen" && (
<>
<Karte
titel="2 · Neuen Key verteilen"
untertitel="Der neue Key kommt zusätzlich in den verwalteten Block – der alte bleibt vorerst."
>
<AusrollenPanel auswahl={{ deploymentKey: true }} offen={stand.hosts.length} />
</Karte>
<Karte
titel="3 · Anmeldung mit dem neuen Key prüfen"
untertitel="sshkv meldet sich auf jedem Host einmal mit dem neuen Key an."
>
<VorgangsKnopf
aktion={rotationsSchrittAktion.bind(null, "pruefen")}
beschriftung="Neuen Key prüfen"
icon={<ShieldCheck className="size-4" />}
/>
{stand.pruefung && (
<ul className="mt-4 flex flex-col gap-1.5" aria-label="Ergebnis der Prüfung">
{stand.pruefung.map((p) => (
<li key={p.hostId} className="flex flex-wrap items-center gap-2 text-[14px]">
<span className="font-mono">{p.hostName}</span>
{p.ok ? (
<Badge variante="ok">angemeldet</Badge>
) : (
<Badge variante="gefahr">fehlgeschlagen</Badge>
)}
{p.fehler && <span className="text-muted text-[13px]">{p.fehler}</span>}
</li>
))}
</ul>
)}
</Karte>
{stand.pruefung && (
<Karte
titel="4 · Umstellen"
untertitel="Ab jetzt benutzt sshkv den neuen Key; der alte gilt als rotiert."
>
<Umstellen fehlgeschlagen={fehlgeschlagen} />
</Karte>
)}
</>
)}
{stand.phase === "aufraeumen" && stand.vorgaenger && (
<Karte
titel="5 · Alten Key entfernen"
untertitel="Dry-Run und Ausrollen entfernen den alten Key aus allen Blöcken; danach wird sein Private Key verworfen."
>
<div className="flex flex-col gap-5">
<p className="text-muted text-[13px]">
Alter Key: <span className="font-mono">{stand.vorgaenger.fingerprint}</span>
</p>
<AusrollenPanel auswahl={{ deploymentKey: true }} offen={stand.hosts.length} />
<div>
<VorgangsKnopf
aktion={rotationsSchrittAktion.bind(null, "abschliessen")}
beschriftung="Rotation abschließen"
variante="primaer"
icon={<CheckCheck className="size-4" />}
/>
</div>
</div>
</Karte>
)}
</div>
);
}
+11
View File
@@ -1,5 +1,6 @@
import { Inbox } from "lucide-react"; import { Inbox } from "lucide-react";
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { BenachrichtigungFormular } from "@/components/einstellungen/BenachrichtigungFormular"; import { BenachrichtigungFormular } from "@/components/einstellungen/BenachrichtigungFormular";
import { Badge } from "@/components/ui/Badge"; import { Badge } from "@/components/ui/Badge";
@@ -161,11 +162,21 @@ export default async function Einstellungen() {
</Info> </Info>
<Info titel="Deployment-Key"> <Info titel="Deployment-Key">
{instanz.deploymentKey ? ( {instanz.deploymentKey ? (
<>
<MonoKopieren <MonoKopieren
wert={instanz.deploymentKey.fingerprint} wert={instanz.deploymentKey.fingerprint}
beschriftung="Fingerprint des Deployment-Keys" beschriftung="Fingerprint des Deployment-Keys"
umbrechen umbrechen
/> />
{darf(benutzer.rolle, "deploymentKeyVerwalten") && (
<Link
href="/deployment-key"
className="text-accent mt-1 inline-flex min-h-11 items-center text-[14px] font-medium hover:underline"
>
Deployment-Key rotieren
</Link>
)}
</>
) : ( ) : (
<span className="text-accent">noch nicht erzeugt – läuft der Worker?</span> <span className="text-accent">noch nicht erzeugt – läuft der Worker?</span>
)} )}
@@ -0,0 +1,75 @@
"use client";
import { Repeat } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/Button";
import { umstellenAktion } from "@/server/actions/deploymentkey";
/**
* Schritt 4 der Rotation (#031). Hosts, auf denen die Pruefung scheiterte, lassen sich bewusst
* uebergehen – sshkv kommt danach nicht mehr auf sie und sie brauchen einen neuen Bootstrap.
*/
export function Umstellen({
fehlgeschlagen,
}: {
fehlgeschlagen: readonly { hostId: string; hostName: string }[];
}) {
const router = useRouter();
const [uebergehen, setUebergehen] = useState<string[]>([]);
const [fehler, setFehler] = useState<string>();
const [laeuft, starten] = useTransition();
const bereit = fehlgeschlagen.every((h) => uebergehen.includes(h.hostId));
return (
<div className="flex flex-col gap-4">
{fehlgeschlagen.length > 0 && (
<fieldset className="flex flex-col gap-2">
<legend className="text-danger mb-2 text-[13.5px]">
Auf diesen Hosts hat der neue Key nicht funktioniert. Übergangene Hosts erreicht sshkv nach dem
Umstellen nicht mehr – sie brauchen einen neuen Bootstrap.
</legend>
{fehlgeschlagen.map((h) => (
<label key={h.hostId} className="flex min-h-11 items-center gap-3 text-[14px]">
<input
type="checkbox"
className="accent-accent size-4"
checked={uebergehen.includes(h.hostId)}
onChange={(e) =>
setUebergehen((alt) =>
e.target.checked ? [...alt, h.hostId] : alt.filter((id) => id !== h.hostId),
)
}
/>
<span>
<span className="font-mono">{h.hostName}</span> übergehen
</span>
</label>
))}
</fieldset>
)}
<div className="flex flex-wrap items-center gap-3">
<Button
variante="primaer"
icon={<Repeat className="size-4" />}
disabled={!bereit || laeuft}
onClick={() =>
starten(async () => {
setFehler(undefined);
const antwort = await umstellenAktion(uebergehen);
if (antwort.fehler) setFehler(antwort.fehler);
else router.refresh();
})
}
>
Auf neuen Key umstellen
</Button>
{fehler && (
<p role="alert" className="text-danger text-[13.5px]">
{fehler}
</p>
)}
</div>
</div>
);
}
+3
View File
@@ -77,6 +77,9 @@ export const AKTIONEN: Readonly<Record<string, Beschreibung>> = {
"privatekey.heruntergeladen": a("Private Key heruntergeladen", "schluessel", false), "privatekey.heruntergeladen": a("Private Key heruntergeladen", "schluessel", false),
"privatekey.geloescht": a("Private Key gelöscht", "schluessel"), "privatekey.geloescht": a("Private Key gelöscht", "schluessel"),
"deploymentkey.erzeugt": a("Deployment-Key erzeugt", "schluessel"), "deploymentkey.erzeugt": a("Deployment-Key erzeugt", "schluessel"),
"deploymentkey.geprueft": a("Neuer Deployment-Key geprüft", "widerruf", false),
"deploymentkey.umgestellt": a("Deployment-Key umgestellt", "widerruf"),
"deploymentkey.abgeschlossen": a("Rotation des Deployment-Keys abgeschlossen", "widerruf"),
// Widerruf und Rotation (#023, #024) // Widerruf und Rotation (#023, #024)
"schluessel.widerruf.eingeleitet": a("Widerruf eingeleitet", "widerruf"), "schluessel.widerruf.eingeleitet": a("Widerruf eingeleitet", "widerruf"),
+44
View File
@@ -0,0 +1,44 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "@/lib/validierung";
import { akteurVon } from "@/server/actions/formular";
import type { VorgangsAntwort } from "@/server/actions/verwendungen";
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { DeploymentKeyFehler, rotationsSchrittAnfordern, umstellen } from "@/server/services/deploymentkey";
/** Rotation des Deployment-Keys (#031). Nur Admins mit frischer Bestaetigung. */
function alsAntwort(fehler: unknown): { fehler: string } {
if (
fehler instanceof DeploymentKeyFehler ||
fehler instanceof RechteFehler ||
fehler instanceof BestaetigungFehler
) {
return { fehler: fehler.message };
}
throw fehler;
}
export async function rotationsSchrittAktion(
schritt: "erzeugen" | "pruefen" | "abschliessen",
): Promise<VorgangsAntwort> {
try {
const { benutzer } = await rechtErforderlich("deploymentKeyVerwalten");
const geprueft = z.enum(["erzeugen", "pruefen", "abschliessen"]).parse(schritt);
return { vorgangId: await rotationsSchrittAnfordern(geprueft, akteurVon(benutzer)) };
} catch (fehler) {
return alsAntwort(fehler);
}
}
export async function umstellenAktion(uebergehen: readonly string[]): Promise<{ fehler?: string }> {
try {
const { benutzer } = await rechtErforderlich("deploymentKeyVerwalten");
await umstellen(akteurVon(benutzer), z.array(z.string().min(1).max(64)).max(500).parse(uebergehen));
revalidatePath("/deployment-key");
return {};
} catch (fehler) {
return alsAntwort(fehler);
}
}
+8 -1
View File
@@ -14,6 +14,7 @@ import {
publicVerwendungEntfernen, publicVerwendungEntfernen,
trockenlaufStarten, trockenlaufStarten,
} from "@/server/services/verwendungen"; } from "@/server/services/verwendungen";
import { instanzZiele } from "@/server/services/deploymentkey";
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge"; import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
/** Public-Key-Zuweisungen und Ausrollen (#016). Duenn: Recht, Eingabe, Service. */ /** Public-Key-Zuweisungen und Ausrollen (#016). Duenn: Recht, Eingabe, Service. */
@@ -78,10 +79,16 @@ export type AusrollAuswahl =
| { readonly schluesselId: string } | { readonly schluesselId: string }
| { readonly hostId: string } | { readonly hostId: string }
/** Ein gemeinsamer Dry-Run fuer mehrere Schluessel (Massenwiderruf, #024). */ /** Ein gemeinsamer Dry-Run fuer mehrere Schluessel (Massenwiderruf, #024). */
| { readonly schluesselIds: readonly string[] }; | { readonly schluesselIds: readonly string[] }
/** Alle Hosts mit dem Instanz-Deployment-Key – fuer dessen Rotation (#031). */
| { readonly deploymentKey: true };
export async function trockenlaufAktion(auswahl: AusrollAuswahl): Promise<VorgangsAntwort> { export async function trockenlaufAktion(auswahl: AusrollAuswahl): Promise<VorgangsAntwort> {
try { try {
if ("deploymentKey" in auswahl) {
const { benutzer } = await rechtErforderlich("deploymentKeyVerwalten");
return { vorgangId: await trockenlaufStarten({ ziele: await instanzZiele() }, akteurVon(benutzer)) };
}
const { benutzer } = await rechtErforderlich("publicKeyAusrollen"); const { benutzer } = await rechtErforderlich("publicKeyAusrollen");
const id = z.string().min(1).max(64); const id = z.string().min(1).max(64);
return { return {
+1 -1
View File
@@ -49,7 +49,7 @@ export function aktionEinordnen(aktion: string, ergebnis: string): AktionsArt {
if (aktion.startsWith("authorized_keys.") || aktion.startsWith("privatekey.ausgerollt")) if (aktion.startsWith("authorized_keys.") || aktion.startsWith("privatekey.ausgerollt"))
return "ausgerollt"; return "ausgerollt";
if (aktion.includes("widerruf")) return "widerrufen"; if (aktion.includes("widerruf")) return "widerrufen";
if (aktion.includes("rotation")) return "rotiert"; if (aktion.includes("rotation") || aktion === "deploymentkey.umgestellt") return "rotiert";
if (aktion === "abgleich") return "abgleich"; if (aktion === "abgleich") return "abgleich";
if (aktion.startsWith("anmeldung") || aktion.startsWith("bestaetigung")) return "anmeldung"; if (aktion.startsWith("anmeldung") || aktion.startsWith("bestaetigung")) return "anmeldung";
if ( if (
+144
View File
@@ -0,0 +1,144 @@
import { prisma } from "@/lib/db";
import { protokollieren } from "@/server/services/protokoll";
import { anmeldebenutzerVon, deploymentNachfolger, type Ziel } from "@/server/services/soll";
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
/**
* Rotation des Instanz-Deployment-Keys (#031) – geführt in fünf Schritten:
*
* 1. erzeugen: Nachfolger anlegen (DEPLOYMENT, nicht Standard, vorgaengerId = alter).
* 2. verteilen: Dry-Run + Ausrollen auf alle Hosts mit Instanz-Key – der Nachfolger kommt
* **neben** den alten in den Block (soll.ts).
* 3. pruefen: je Host eine Anmeldung mit dem neuen Key.
* 4. umstellen: Nachfolger wird Standard, alter ROTIERT; danach Dry-Run + Ausrollen entfernt den
* alten aus allen Blöcken.
* 5. abschliessen: auf jedem Host nachsehen, dass der alte weg ist, dann seinen Private Key
* verwerfen.
*
* Zu keinem Zeitpunkt steht ein Host ohne funktionierenden Deployment-Key da: umgestellt wird
* erst, wenn der neue Key überall geprüft ist (oder ein Admin einen Host bewusst übergeht –
* der braucht danach einen neuen Bootstrap).
*/
export class DeploymentKeyFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "DeploymentKeyFehler";
}
}
type Akteur = { readonly id: string; readonly name: string };
export type PruefErgebnis = { hostId: string; hostName: string; ok: boolean; fehler?: string };
/** Hosts, die den Instanz-Key benutzen (kein eigener, keiner über eine Gruppe). */
export async function instanzHosts() {
return prisma.host.findMany({
where: {
archiviertAm: null,
bootstrapAm: { not: null },
deploymentSchluesselId: null,
gruppen: { none: { hostgruppe: { deploymentSchluesselId: { not: null } } } },
},
select: { id: true, name: true, anmeldeart: true },
orderBy: { name: "asc" },
});
}
export async function instanzZiele(): Promise<Ziel[]> {
return (await instanzHosts()).map((h) => ({
hostId: h.id,
zielbenutzer: anmeldebenutzerVon(h),
art: "public",
}));
}
export async function rotationsStand() {
const aktuell = await prisma.schluessel.findFirst({
where: { kategorie: "DEPLOYMENT", instanzStandard: true, status: "AKTIV" },
select: { id: true, fingerprint: true, angelegtAm: true, vorgaengerId: true },
});
const nachfolger = await deploymentNachfolger();
// Nach dem Umstellen: der alte ist ROTIERT, sein Private Key noch nicht verworfen.
const vorgaenger = aktuell?.vorgaengerId
? await prisma.schluessel.findFirst({
where: {
id: aktuell.vorgaengerId,
kategorie: "DEPLOYMENT",
status: "ROTIERT",
privatGeloeschtAm: null,
},
select: { id: true, fingerprint: true },
})
: null;
const letztePruefung = await prisma.vorgang.findFirst({
where: { art: "ROTATION", eingabe: { path: ["schritt"], equals: "pruefen" } },
orderBy: { angelegtAm: "desc" },
select: { id: true, status: true, ergebnis: true, beendetAm: true, angelegtAm: true },
});
const hosts = await instanzHosts();
const pruefung =
letztePruefung && nachfolger && letztePruefung.angelegtAm > nachfolger.angelegtAm
? ((letztePruefung.ergebnis as { hosts?: PruefErgebnis[] } | null)?.hosts ?? null)
: null;
const phase: "bereit" | "verteilen" | "aufraeumen" = nachfolger
? "verteilen"
: vorgaenger
? "aufraeumen"
: "bereit";
return { aktuell, nachfolger, vorgaenger, phase, hosts, pruefung };
}
export async function rotationsSchrittAnfordern(
schritt: "erzeugen" | "pruefen" | "abschliessen",
akteur: Akteur,
): Promise<string> {
const stand = await rotationsStand();
if (schritt === "erzeugen" && stand.phase !== "bereit") {
throw new DeploymentKeyFehler("Es läuft bereits eine Rotation des Deployment-Keys.");
}
if (schritt === "pruefen" && !stand.nachfolger)
throw new DeploymentKeyFehler("Es gibt keinen neuen Deployment-Key zu prüfen.");
if (schritt === "abschliessen" && !stand.vorgaenger)
throw new DeploymentKeyFehler("Es gibt nichts abzuschließen.");
return vorgangEinreihen({ art: "ROTATION", angefordertVonId: akteur.id, eingabe: { schritt } });
}
/**
* Schritt 4: Nachfolger wird Standard. Verlangt eine Prüfung nach dem Erzeugen, in der jeder
* Host den neuen Key angenommen hat – oder ausdrücklich übergangen wird.
*/
export async function umstellen(akteur: Akteur, uebergehen: readonly string[] = []): Promise<void> {
const stand = await rotationsStand();
if (!stand.nachfolger || !stand.aktuell)
throw new DeploymentKeyFehler("Es gibt keinen neuen Deployment-Key.");
if (!stand.pruefung) throw new DeploymentKeyFehler("Erst den neuen Key auf allen Hosts prüfen.");
const geprueft = new Map(stand.pruefung.map((p) => [p.hostId, p]));
const offen = stand.hosts.filter((h) => !geprueft.get(h.id)?.ok && !uebergehen.includes(h.id));
if (offen.length) {
throw new DeploymentKeyFehler(
`Der neue Key ist noch nicht auf allen Hosts geprüft: ${offen.map((h) => h.name).join(", ")}.`,
);
}
const alt = stand.aktuell;
const neu = stand.nachfolger;
await prisma.$transaction([
// Erst den alten abloesen, dann den neuen setzen – der partielle Index erlaubt nur einen Standard.
prisma.schluessel.update({
where: { id: alt.id },
data: { instanzStandard: false, status: "ROTIERT" },
select: { id: true },
}),
prisma.schluessel.update({
where: { id: neu.id },
data: { instanzStandard: true },
select: { id: true },
}),
]);
await protokollieren({
akteur,
aktion: "deploymentkey.umgestellt",
ergebnis: "ERFOLG",
fingerprint: neu.fingerprint,
details: { alt: alt.fingerprint, uebergangen: [...uebergehen] },
});
}
+29
View File
@@ -46,6 +46,23 @@ async function deploymentKeyFuer(host: Pick<Host, "id" | "deploymentSchluesselId
}); });
} }
/**
* Der Nachfolger des Instanz-Deployment-Keys waehrend einer Rotation (#031): neu erzeugt,
* aber noch nicht Standard. Er gehoert schon in jeden Block – neben den alten –, damit kein
* Host je ohne funktionierenden Deployment-Key dasteht.
*/
export async function deploymentNachfolger() {
return prisma.schluessel.findFirst({
where: {
kategorie: "DEPLOYMENT",
instanzStandard: false,
status: "AKTIV",
vorgaenger: { kategorie: "DEPLOYMENT", instanzStandard: true, status: "AKTIV" },
},
select: { id: true, publicKey: true, fingerprint: true, vorgaengerId: true, angelegtAm: true },
});
}
export async function sollFuer(hostId: string, zielbenutzer: string): Promise<Soll> { export async function sollFuer(hostId: string, zielbenutzer: string): Promise<Soll> {
const host = await prisma.host.findUniqueOrThrow({ const host = await prisma.host.findUniqueOrThrow({
where: { id: hostId }, where: { id: hostId },
@@ -77,6 +94,18 @@ export async function sollFuer(hostId: string, zielbenutzer: string): Promise<So
eintraege.push({ publicKey: deployment.publicKey, fingerprint: deployment.fingerprint }); eintraege.push({ publicKey: deployment.publicKey, fingerprint: deployment.fingerprint });
pflicht.push(deployment.fingerprint); pflicht.push(deployment.fingerprint);
} }
// Nur Hosts mit dem Instanz-Key nehmen an dessen Rotation teil (#031).
const eigener = host.deploymentSchluesselId !== null;
const nachfolger = eigener ? null : await deploymentNachfolger();
if (nachfolger && (await deploymentKeyFuer(host))?.fingerprint !== nachfolger.fingerprint) {
const gruppe = await prisma.hostgruppeMitglied.count({
where: { hostId: host.id, hostgruppe: { deploymentSchluesselId: { not: null } } },
});
if (gruppe === 0) {
eintraege.push({ publicKey: nachfolger.publicKey, fingerprint: nachfolger.fingerprint });
pflicht.push(nachfolger.fingerprint);
}
}
} }
const drin: string[] = []; const drin: string[] = [];
+148
View File
@@ -0,0 +1,148 @@
import { hostname } from "node:os";
import { prisma } from "@/lib/db";
import { authorizedKeysLesen, type SchluesselZeile } from "@/lib/ssh/authorized-keys";
import { instanzHosts, rotationsStand, type PruefErgebnis } from "@/server/services/deploymentkey";
import { protokollieren } from "@/server/services/protokoll";
import { anmeldebenutzerVon } from "@/server/services/soll";
import { VorgangsFehler, type Handler } from "@/worker/ausfuehren";
import { privateKeyLaden, privateKeyVerwerfen, schluesselpaarVersiegeltErzeugen } from "@/worker/geheimnisse";
import { hostVerbinden } from "@/worker/ssh/host";
import { authorizedKeysHolen } from "@/worker/ssh/ssh-dateien";
import { befehlAusfuehren } from "@/worker/ssh/verbindung";
/** Worker-Schritte der Rotation des Deployment-Keys (#031): erzeugen, pruefen, abschliessen. */
function fehlerText(fehler: unknown): string {
return fehler instanceof VorgangsFehler ? fehler.message : "Verbindung fehlgeschlagen.";
}
async function akteurVon(id: string | null) {
const benutzer = id
? await prisma.benutzer.findUnique({ where: { id }, select: { id: true, name: true } })
: null;
return benutzer ?? ("System" as const);
}
async function erzeugen(angefordertVonId: string | null) {
const stand = await rotationsStand();
if (!stand.aktuell || stand.phase !== "bereit") throw new VorgangsFehler("Es läuft bereits eine Rotation.");
const datum = new Date().toISOString().slice(0, 10);
const kommentar = `sshkv-deployment@${hostname()}-${datum}`;
const neu = await schluesselpaarVersiegeltErzeugen("ED25519", kommentar);
const anzahl = await prisma.schluessel.count({ where: { kategorie: "DEPLOYMENT" } });
await prisma.schluessel.create({
data: {
id: neu.id,
name: `Deployment-Key der Instanz (${datum})`,
slug: `sshkv-deployment-${anzahl + 1}`,
typ: "ED25519",
kategorie: "DEPLOYMENT",
publicKey: neu.publicKeyZeile,
fingerprint: neu.fingerprint,
kommentar,
vorgaengerId: stand.aktuell.id,
...neu.versiegelt,
},
});
await protokollieren({
akteur: await akteurVon(angefordertVonId),
aktion: "deploymentkey.erzeugt",
ergebnis: "ERFOLG",
fingerprint: neu.fingerprint,
details: { vorgaenger: stand.aktuell.fingerprint },
});
return { ergebnis: { fingerprint: neu.fingerprint } };
}
/** Meldet sich auf jedem Instanz-Host mit dem NEUEN Key an. Wirft nicht je Host. */
async function pruefen(angefordertVonId: string | null) {
const stand = await rotationsStand();
if (!stand.nachfolger) throw new VorgangsFehler("Es gibt keinen neuen Deployment-Key.");
const hosts: PruefErgebnis[] = [];
const privateKey = await privateKeyLaden(stand.nachfolger.id);
try {
for (const kurz of await instanzHosts()) {
const host = await prisma.host.findUniqueOrThrow({ where: { id: kurz.id } });
try {
const verbindung = await hostVerbinden(host, { benutzer: anmeldebenutzerVon(host), privateKey });
try {
const wer = await befehlAusfuehren(verbindung, "id -un");
hosts.push({
hostId: host.id,
hostName: host.name,
ok: wer.stdout.trim() === anmeldebenutzerVon(host),
});
} finally {
verbindung.end();
}
} catch (fehler) {
hosts.push({ hostId: host.id, hostName: host.name, ok: false, fehler: fehlerText(fehler) });
}
}
} finally {
privateKey.fill(0);
}
await protokollieren({
akteur: await akteurVon(angefordertVonId),
aktion: "deploymentkey.geprueft",
ergebnis: hosts.every((h) => h.ok) ? "ERFOLG" : "FEHLER",
fingerprint: stand.nachfolger.fingerprint,
details: {
ok: hosts.filter((h) => h.ok).length,
fehler: hosts.filter((h) => !h.ok).map((h) => h.hostName),
},
});
return { ergebnis: { hosts } };
}
/**
* Prueft auf jedem Instanz-Host, dass der alte Key nicht mehr im Block steht, und verwirft
* erst dann seinen Private Key. Nicht erreichbare Hosts verhindern den Abschluss nicht – sie
* melden sich mit dem alten Key ohnehin nicht mehr an, weil sshkv ihn nicht mehr benutzt –,
* werden aber im Ergebnis genannt.
*/
async function abschliessen(angefordertVonId: string | null) {
const stand = await rotationsStand();
if (!stand.vorgaenger) throw new VorgangsFehler("Es gibt nichts abzuschließen.");
const nochDa: string[] = [];
const nichtErreicht: string[] = [];
for (const kurz of await instanzHosts()) {
const host = await prisma.host.findUniqueOrThrow({ where: { id: kurz.id } });
try {
const verbindung = await hostVerbinden(host);
try {
const stand2 = await authorizedKeysHolen(verbindung, host, anmeldebenutzerVon(host));
const zeilen = authorizedKeysLesen(stand2.inhalt).zeilen.filter(
(z): z is SchluesselZeile => z.art === "schluessel",
);
if (zeilen.some((z) => z.fingerprint === stand.vorgaenger!.fingerprint)) nochDa.push(host.name);
} finally {
verbindung.end();
}
} catch {
nichtErreicht.push(host.name);
}
}
if (nochDa.length) {
throw new VorgangsFehler(
`Der alte Deployment-Key steht noch auf: ${nochDa.join(", ")}. Bitte zuerst Dry-Run und Ausrollen.`,
);
}
await privateKeyVerwerfen(stand.vorgaenger.id);
await protokollieren({
akteur: await akteurVon(angefordertVonId),
aktion: "deploymentkey.abgeschlossen",
ergebnis: "ERFOLG",
fingerprint: stand.vorgaenger.fingerprint,
details: { nichtErreicht },
});
return { ergebnis: { nichtErreicht } };
}
export const deploymentRotationHandler: Handler = async ({ vorgang }) => {
const schritt = (vorgang.eingabe as { schritt?: unknown } | null)?.schritt;
if (schritt === "erzeugen") return erzeugen(vorgang.angefordertVonId);
if (schritt === "pruefen") return pruefen(vorgang.angefordertVonId);
if (schritt === "abschliessen") return abschliessen(vorgang.angefordertVonId);
throw new VorgangsFehler("Unbekannter Schritt der Rotation.");
};
+2
View File
@@ -3,6 +3,7 @@ import { abgleichHandler } from "@/worker/ablaeufe/abgleich";
import { benachrichtigungTestHandler } from "@/worker/ablaeufe/benachrichtigung"; import { benachrichtigungTestHandler } from "@/worker/ablaeufe/benachrichtigung";
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 { deploymentRotationHandler } from "@/worker/ablaeufe/deploymentkey";
import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey"; import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey";
import { privateKeyAbholenHandler, privateKeyLoeschenHandler } from "@/worker/ablaeufe/privatekey"; import { privateKeyAbholenHandler, privateKeyLoeschenHandler } from "@/worker/ablaeufe/privatekey";
import { schluesselErzeugenHandler, schluesselImportierenHandler } from "@/worker/ablaeufe/schluessel"; import { schluesselErzeugenHandler, schluesselImportierenHandler } from "@/worker/ablaeufe/schluessel";
@@ -24,6 +25,7 @@ export const HANDLER: HandlerTabelle = {
PRIVATE_KEY_LOESCHEN: privateKeyLoeschenHandler, PRIVATE_KEY_LOESCHEN: privateKeyLoeschenHandler,
VERBINDUNGSTEST: verbindungstestHandler, VERBINDUNGSTEST: verbindungstestHandler,
ABGLEICH: abgleichHandler, ABGLEICH: abgleichHandler,
ROTATION: deploymentRotationHandler,
ZIELKONTO_ANLEGEN: zielkontoAnlegenHandler, ZIELKONTO_ANLEGEN: zielkontoAnlegenHandler,
ZIELKONTO_ENTFERNEN: zielkontoEntfernenHandler, ZIELKONTO_ENTFERNEN: zielkontoEntfernenHandler,
BENACHRICHTIGUNG_TEST: benachrichtigungTestHandler, BENACHRICHTIGUNG_TEST: benachrichtigungTestHandler,
+78
View File
@@ -0,0 +1,78 @@
import { expect, test, type Page } from "@playwright/test";
import { anmelden, archivieren, aufZiel, hostEinrichten, PORT, zieleZuruecksetzen } from "./hilfen";
/**
* Rotation des Deployment-Keys im Browser (#031), gebauter Stand + Worker: zwei frisch
* eingerichtete Hosts, fuenf Schritte, danach meldet sich sshkv nur noch mit dem neuen Key an.
*/
test.setTimeout(300_000);
test.beforeAll(() => zieleZuruecksetzen());
async function bestaetigen(page: Page) {
await page.goto("/bestaetigen?ziel=%2Fdeployment-key");
await page.getByRole("link", { name: /erneut anmelden/ }).click();
await page.locator('button[data-rolle="admin"]').click();
const claims = JSON.parse(await page.locator("#claims").inputValue()) as Record<string, unknown>;
await page.locator("#claims").fill(JSON.stringify({ ...claims, auth_time: Math.floor(Date.now() / 1000) }));
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
await expect(page.getByRole("heading", { name: "Deployment-Key rotieren" })).toBeVisible();
}
test("Deployment-Key rotieren", async ({ page }) => {
const kennung = Date.now().toString(36);
const hostA = `zz-e2e-a-${kennung}`;
const hostB = `zz-e2e-b-${kennung}`;
await anmelden(page);
await hostEinrichten(page, hostA, PORT.a);
await hostEinrichten(page, hostB, PORT.b);
const vorher = aufZiel("ziel-a", "cat /home/sshkv/.ssh/authorized_keys");
await page.goto("/einstellungen");
await page.getByRole("link", { name: "Deployment-Key rotieren" }).click();
await bestaetigen(page);
await page.getByRole("button", { name: "Neuen Deployment-Key erzeugen" }).click();
await expect(page.getByText("Neuer Key (noch nicht Standard)")).toBeVisible({ timeout: 30_000 });
// Schritt 2: verteilen – der neue Key kommt neben den alten.
await page.getByRole("button", { name: "Dry-Run starten" }).click();
await expect(page.getByRole("button", { name: /Änderung anwenden/ })).toBeVisible({ timeout: 30_000 });
await page.screenshot({ path: "docs/anleitung/bilder/deployment-key-desktop.png", fullPage: true });
await page.getByRole("button", { name: /Änderung anwenden/ }).click();
await expect(page.getByText("Ausgerollt.", { exact: true })).toBeVisible({ timeout: 60_000 });
expect(aufZiel("ziel-a", "grep -c sshkv-deployment /home/sshkv/.ssh/authorized_keys").trim()).toBe("2");
// Schritt 3 und 4: pruefen, umstellen.
await page.getByRole("button", { name: "Neuen Key prüfen" }).click();
const ergebnis = page.getByRole("list", { name: "Ergebnis der Prüfung" });
await expect(ergebnis.getByText("angemeldet")).toHaveCount(2, { timeout: 60_000 });
await page.getByRole("button", { name: "Auf neuen Key umstellen" }).click();
// Schritt 5: alten Key entfernen und abschliessen.
await expect(page.getByRole("heading", { name: "5 · Alten Key entfernen" })).toBeVisible({
timeout: 30_000,
});
await page.getByRole("button", { name: "Dry-Run starten" }).click();
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
await expect(page.getByText("Ausgerollt.", { exact: true })).toBeVisible({ timeout: 60_000 });
await page.getByRole("button", { name: "Rotation abschließen" }).click();
await expect(page.getByRole("heading", { name: "1 · Neuen Key erzeugen" })).toBeVisible({
timeout: 60_000,
});
const nachher = aufZiel("ziel-a", "cat /home/sshkv/.ssh/authorized_keys");
expect(nachher.match(/sshkv-deployment/g)).toHaveLength(1);
expect(nachher).not.toBe(vorher);
// Der Alltag geht weiter: Verbindung pruefen klappt mit dem neuen Key.
await page.goto("/hosts");
await page.getByRole("link", { name: hostA }).first().click();
const pruefen = page.getByRole("button", { name: "Verbindung prüfen" });
await pruefen.click();
await expect(pruefen.locator("..").getByText("Erledigt.")).toBeVisible({ timeout: 30_000 });
await archivieren(page, hostA);
await archivieren(page, hostB);
});
+147
View File
@@ -0,0 +1,147 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import { authorizedKeysLesen } from "@/lib/ssh/authorized-keys";
import {
DeploymentKeyFehler,
instanzZiele,
rotationsStand,
umstellen,
} from "@/server/services/deploymentkey";
import { anmeldebenutzerVon } from "@/server/services/soll";
import { deploymentKeySicherstellen, privateKeyLaden } from "@/worker/geheimnisse";
import { hostVerbinden } from "@/worker/ssh/host";
import { authorizedKeysHolen } from "@/worker/ssh/ssh-dateien";
import { datenbankLeeren } from "../hilfen/db";
import { warteschlangeStoppen } from "../hilfen/vorgang";
import { eingerichteterHost, masterKeyAnlegen, vorgangLaufen } from "./hilfen/host";
import { befehlAlsRoot, zielA, zielB, zielBereit } from "./hilfen/ziele";
/**
* Rotation des Deployment-Keys gegen echte Hosts (#031): danach meldet sich sshkv nur noch mit
* dem neuen Key an, der alte steht auf keinem Host mehr, und zu keinem Zeitpunkt war ein Host
* ohne funktionierenden Key.
*/
const AKTEUR = { id: "", name: "Test-Admin" };
let aufraeumen: () => void;
beforeAll(async () => {
await Promise.all([zielBereit(zielA), zielBereit(zielB)]);
aufraeumen = masterKeyAnlegen();
});
afterAll(async () => {
aufraeumen();
await warteschlangeStoppen();
});
beforeEach(async () => {
await datenbankLeeren();
await deploymentKeySicherstellen();
const reset = "userdel -r sshkv 2>/dev/null; rm -f /etc/sudoers.d/sshkv*; rm -rf /root/.ssh; true";
await Promise.all([befehlAlsRoot(zielA, reset), befehlAlsRoot(zielB, reset)]);
AKTEUR.id = (await prisma.benutzer.create({ data: { name: "Test-Admin", rolle: "ADMIN" } })).id;
});
const schritt = (s: "erzeugen" | "pruefen" | "abschliessen") =>
vorgangLaufen("ROTATION", { eingabe: { schritt: s }, angefordertVonId: AKTEUR.id });
async function trockenlaufUndAusrollen() {
const trockenlauf = await vorgangLaufen("TROCKENLAUF", {
eingabe: { ziele: await instanzZiele() },
angefordertVonId: AKTEUR.id,
});
expect(trockenlauf.status).toBe("ERLEDIGT");
const ausrollen = await vorgangLaufen("AUSROLLEN", {
eingabe: { trockenlaufId: trockenlauf.id },
angefordertVonId: AKTEUR.id,
});
expect(ausrollen.fehler).toBeNull();
}
type Host = Awaited<ReturnType<typeof eingerichteterHost>>;
async function fingerprintsAuf(host: Host): Promise<string[]> {
const verbindung = await hostVerbinden(host);
try {
const datei = await authorizedKeysHolen(verbindung, host, anmeldebenutzerVon(host));
return authorizedKeysLesen(datei.inhalt).zeilen.flatMap((z) =>
z.art === "schluessel" ? [z.fingerprint] : [],
);
} finally {
verbindung.end();
}
}
async function anmeldungKlappt(host: Host, schluesselId: string): Promise<boolean> {
const privateKey = await privateKeyLaden(schluesselId);
try {
const verbindung = await hostVerbinden(host, { benutzer: anmeldebenutzerVon(host), privateKey });
verbindung.end();
return true;
} catch {
return false;
} finally {
privateKey.fill(0);
}
}
describe("Rotation des Deployment-Keys", () => {
it("tauscht den Key auf allen Hosts ohne Unterbrechung aus", async () => {
const a = await eingerichteterHost(zielA, "ziel-a");
const b = await eingerichteterHost(zielB, "ziel-b", "ROOT");
const alt = await prisma.schluessel.findFirstOrThrow({ where: { instanzStandard: true } });
expect((await schritt("erzeugen")).status).toBe("ERLEDIGT");
const neu = (await rotationsStand()).nachfolger!;
expect(neu.vorgaengerId).toBe(alt.id);
// Ein zweites Erzeugen waehrend der Rotation wird abgelehnt.
expect((await schritt("erzeugen")).status).toBe("FEHLGESCHLAGEN");
// Vor dem Verteilen scheitert die Pruefung – und ohne erfolgreiche Pruefung kein Umstellen.
await schritt("pruefen");
expect((await rotationsStand()).pruefung?.every((p) => !p.ok)).toBe(true);
await expect(umstellen(AKTEUR)).rejects.toThrow(DeploymentKeyFehler);
await trockenlaufUndAusrollen();
for (const host of [a, b]) {
expect(await fingerprintsAuf(host)).toEqual(expect.arrayContaining([alt.fingerprint, neu.fingerprint]));
}
await schritt("pruefen");
expect((await rotationsStand()).pruefung?.map((p) => p.ok)).toEqual([true, true]);
await umstellen(AKTEUR);
const umgestellt = await rotationsStand();
expect(umgestellt.aktuell?.id).toBe(neu.id);
expect(umgestellt.phase).toBe("aufraeumen");
// Abschliessen geht erst, wenn der alte Key ueberall entfernt ist.
expect((await schritt("abschliessen")).fehler).toMatch(/steht noch auf/);
await trockenlaufUndAusrollen();
for (const host of [a, b]) {
const fingerprints = await fingerprintsAuf(host);
expect(fingerprints).toContain(neu.fingerprint);
expect(fingerprints).not.toContain(alt.fingerprint);
expect(await anmeldungKlappt(host, alt.id)).toBe(false);
expect(await anmeldungKlappt(host, neu.id)).toBe(true);
}
expect((await schritt("abschliessen")).status).toBe("ERLEDIGT");
const verworfen = await prisma.schluessel.findUniqueOrThrow({ where: { id: alt.id } });
expect(verworfen.status).toBe("ROTIERT");
expect(verworfen.privatGeloeschtAm).not.toBeNull();
expect((await rotationsStand()).phase).toBe("bereit");
// Der Alltag geht mit dem neuen Key weiter.
const abgleich = await vorgangLaufen("ABGLEICH", { hostId: a.id });
expect(abgleich.status).toBe("ERLEDIGT");
expect(await prisma.abweichung.count({ where: { hostId: a.id, behobenAm: null } })).toBe(0);
});
it("laesst Hosts mit eigenem Deployment-Key aus", async () => {
const a = await eingerichteterHost(zielA, "ziel-a");
const b = await eingerichteterHost(zielB, "ziel-b");
const eigener = await prisma.schluessel.findFirstOrThrow({ where: { instanzStandard: true } });
// b bekommt (formal) einen eigenen Key – derselbe genuegt, es zaehlt die Zuordnung.
await prisma.host.update({ where: { id: b.id }, data: { deploymentSchluesselId: eigener.id } });
expect((await instanzZiele()).map((z) => z.hostId)).toEqual([a.id]);
});
});
+2 -1
View File
@@ -29,7 +29,8 @@ export async function vorgangLaufen(
| "VERBINDUNGSTEST" | "VERBINDUNGSTEST"
| "ZIELKONTO_ANLEGEN" | "ZIELKONTO_ANLEGEN"
| "ZIELKONTO_ENTFERNEN" | "ZIELKONTO_ENTFERNEN"
| "ABGLEICH", | "ABGLEICH"
| "ROTATION",
daten: { daten: {
hostId?: string; hostId?: string;
eingabe?: object; eingabe?: object;