2 Commits
Author SHA1 Message Date
florian.wilde 3e0176dc0c Merge branch 'phase/031-deploymentkey-rotation' 2026-10-12 17:27:25 +02:00
florian.wildeandClaude Opus 5.5 cab6f45510 feat(deploymentkey): geführte Rotation des Deployment-Keys (#031)
Bisher ließ sich der Instanz-Key nur von Hand auf jedem Host tauschen.
Die Rotation läuft jetzt in fünf Schritten: Nachfolger erzeugen, per
Dry-Run neben den alten verteilen, Anmeldung je Host prüfen, umstellen,
alten Key aus allen Blöcken entfernen und seinen Private Key verwerfen.

Der Nachfolger steht während der Rotation in Block und Pflichtliste,
damit kein Host ohne funktionierenden Key dasteht. Umgestellt wird nur
nach erfolgreicher Prüfung oder bewusstem Übergehen eines Hosts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:27:09 +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]
### 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
+8
View File
@@ -170,6 +170,14 @@ auswählen (nur Admin).
![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
- **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
vergleichen; `protokoll_kette_pruefen()` zeigt manipulierte Zeilen.
> Eine eingebaute **Rotation des Deployment-Keys** gibt es noch nicht (siehe `docs/issues`). Bis dahin
> ist Schritt 2 Handarbeit.
> **Rotation des Deployment-Keys** (Einstellungen → Instanz → „Deployment-Key rotieren“, nur Admin mit
> 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
+15 -10
View File
@@ -1,25 +1,30 @@
# #031 Rotation des Deployment-Keys
**Status:** offen
**Status:** erledigt
**Meilenstein:** v1.0.0
**Labels:** sicherheit, backend
**Geschätzt:** 3 h **Tatsächlich:** –
**Geschätzt:** 3 h **Tatsächlich:** 1 h 10 min
## Ziel
Den Deployment-Key der Instanz geführt tauschen – regelmäßig oder im Notfall (sshkv kompromittiert, siehe `docs/betrieb/sicherheit.md`).
## Aufgaben
- [ ] 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)
- [ ] 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
- [ ] Nicht erreichbare Hosts sichtbar machen; Abschluss erst, wenn alle umgestellt sind (oder bewusst übergangen)
- [ ] Nur Admin mit frischer Bestätigung, alles protokolliert
- [x] Neuen Deployment-Key erzeugen, ohne den alten abzulösen (zweiter Instanz-Key im Übergang)
- [x] Auf jedem eingerichteten Host den neuen Key neben den alten in den verwalteten Block des Anmeldebenutzers schreiben (Dry-Run, Ausrollen)
- [x] Verbindung mit dem neuen Key je Host prüfen
- [x] Erst wenn alle Hosts den neuen Key akzeptieren: alten aus allen Blöcken entfernen, alten Key verwerfen
- [x] Nicht erreichbare Hosts sichtbar machen; Abschluss erst, wenn alle umgestellt sind (oder bewusst übergangen)
- [x] Nur Admin mit frischer Bestätigung, alles protokolliert
## Akzeptanzkriterien
- [ ] 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] Integrationstest: nach der Rotation meldet sich sshkv nur noch mit dem neuen Key an, der alte steht auf keinem Host mehr
- [x] Aussperrschutz: zu keinem Zeitpunkt steht ein Host ohne funktionierenden Deployment-Key da
## Abhängigkeiten
#016, #021
## 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>
);
}
+16 -5
View File
@@ -1,5 +1,6 @@
import { Inbox } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import type { ReactNode } from "react";
import { BenachrichtigungFormular } from "@/components/einstellungen/BenachrichtigungFormular";
import { Badge } from "@/components/ui/Badge";
@@ -161,11 +162,21 @@ export default async function Einstellungen() {
</Info>
<Info titel="Deployment-Key">
{instanz.deploymentKey ? (
<MonoKopieren
wert={instanz.deploymentKey.fingerprint}
beschriftung="Fingerprint des Deployment-Keys"
umbrechen
/>
<>
<MonoKopieren
wert={instanz.deploymentKey.fingerprint}
beschriftung="Fingerprint des Deployment-Keys"
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>
)}
@@ -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.geloescht": a("Private Key gelöscht", "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)
"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,
trockenlaufStarten,
} from "@/server/services/verwendungen";
import { instanzZiele } from "@/server/services/deploymentkey";
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
/** Public-Key-Zuweisungen und Ausrollen (#016). Duenn: Recht, Eingabe, Service. */
@@ -78,10 +79,16 @@ export type AusrollAuswahl =
| { readonly schluesselId: string }
| { readonly hostId: string }
/** 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> {
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 id = z.string().min(1).max(64);
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"))
return "ausgerollt";
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.startsWith("anmeldung") || aktion.startsWith("bestaetigung")) return "anmeldung";
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> {
const host = await prisma.host.findUniqueOrThrow({
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 });
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[] = [];
+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 { ausrollenHandler, trockenlaufHandler } from "@/worker/ablaeufe/ausrollen";
import { bootstrapHandler } from "@/worker/ablaeufe/bootstrap";
import { deploymentRotationHandler } from "@/worker/ablaeufe/deploymentkey";
import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey";
import { privateKeyAbholenHandler, privateKeyLoeschenHandler } from "@/worker/ablaeufe/privatekey";
import { schluesselErzeugenHandler, schluesselImportierenHandler } from "@/worker/ablaeufe/schluessel";
@@ -24,6 +25,7 @@ export const HANDLER: HandlerTabelle = {
PRIVATE_KEY_LOESCHEN: privateKeyLoeschenHandler,
VERBINDUNGSTEST: verbindungstestHandler,
ABGLEICH: abgleichHandler,
ROTATION: deploymentRotationHandler,
ZIELKONTO_ANLEGEN: zielkontoAnlegenHandler,
ZIELKONTO_ENTFERNEN: zielkontoEntfernenHandler,
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"
| "ZIELKONTO_ANLEGEN"
| "ZIELKONTO_ENTFERNEN"
| "ABGLEICH",
| "ABGLEICH"
| "ROTATION",
daten: {
hostId?: string;
eingabe?: object;