Compare commits
4
Commits
v0.5.0
..
3e0176dc0c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e0176dc0c | ||
|
|
cab6f45510 | ||
|
|
75077f9101 | ||
|
|
87b733cc9f |
@@ -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
|
||||
|
||||
|
||||
@@ -170,6 +170,14 @@ auswählen (nur Admin).
|
||||
|
||||

|
||||
|
||||
**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.
|
||||
|
||||

|
||||
|
||||
## 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 |
@@ -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
|
||||
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
# #030 Issues nach Gitea übertragen
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.1.0
|
||||
**Labels:** chore
|
||||
**Geschätzt:** 1 h **Tatsächlich:** –
|
||||
**Geschätzt:** 1 h **Tatsächlich:** 0 h 10 min
|
||||
|
||||
## Ziel
|
||||
Issue-Dateien, Labels und Meilensteine in Gitea anlegen (wartet auf Zugangsdaten).
|
||||
|
||||
## Aufgaben
|
||||
- [ ] `scripts/gitea-issues.ts`
|
||||
- [x] `scripts/gitea-issues.ts`
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] wird zu Beginn der Phase verfeinert
|
||||
- [x] wird zu Beginn der Phase verfeinert
|
||||
|
||||
## Abhängigkeiten
|
||||
–
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
- Repo `florian.wilde/sshkeyvault` auf git.wilde-server.de (von Florian angelegt), `main` und Tags v0.1.0–v0.5.0 gepusht.
|
||||
- `pnpm issues:sync` liest Adresse und Repo aus dem Git-Remote und den Token aus den Git-Zugangsdaten; die Issue-Dateien bleiben die Quelle der Wahrheit.
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# #031 Rotation des Deployment-Keys
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v1.0.0
|
||||
**Labels:** sicherheit, backend
|
||||
**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
|
||||
- [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
|
||||
- [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.
|
||||
@@ -0,0 +1,22 @@
|
||||
# #032 Nacharbeiten aus Phase 4/5
|
||||
**Status:** offen
|
||||
**Meilenstein:** v1.0.0
|
||||
**Labels:** test, chore
|
||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
||||
|
||||
## Ziel
|
||||
Kleinere offene Punkte aus den Berichten der Phasen 4 und 5 erledigen.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] Massenwiderruf im Browser testen (E2E mit frischer Bestätigung über den Mock-OIDC)
|
||||
- [ ] Laufende Rotation abbrechen können (Nachfolger widerrufen, Vorgänger wieder aktiv)
|
||||
- [ ] Protokoll-IDs ohne Zweierschritte (Spalten-Default und Trigger ziehen beide `nextval`)
|
||||
- [ ] „Zum Dry-Run“ aus der Zuweisungsmatrix direkt zur Dry-Run-Karte springen (Anker)
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] Alle Suiten grün
|
||||
|
||||
## Abhängigkeiten
|
||||
#023, #024, #025, #027
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
+18
-8
@@ -76,14 +76,15 @@ class Gitea {
|
||||
constructor(
|
||||
private readonly basis: string,
|
||||
private readonly repo: string,
|
||||
private readonly token: string,
|
||||
/** Fertiger Wert fuer den Authorization-Header (`token …` oder `Basic …`). */
|
||||
private readonly autorisierung: string,
|
||||
) {}
|
||||
|
||||
private async anfrage<T>(pfad: string, optionen: RequestInit = {}): Promise<T> {
|
||||
const antwort = await fetch(`${this.basis}/api/v1/repos/${this.repo}${pfad}`, {
|
||||
...optionen,
|
||||
headers: {
|
||||
Authorization: `token ${this.token}`,
|
||||
Authorization: this.autorisierung,
|
||||
"Content-Type": "application/json",
|
||||
...optionen.headers,
|
||||
},
|
||||
@@ -149,10 +150,12 @@ function ausRemote(): { basis: string; repo: string } | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt den Token aus den Git-Zugangsdaten (Schlüsselbund oder `~/.git-credentials`).
|
||||
* So genügt ein einziger Token für Push und API – er muss nicht zusätzlich in der .env stehen.
|
||||
* Holt die Zugangsdaten aus Git (Schlüsselbund oder `~/.git-credentials`), damit sie nicht
|
||||
* zusätzlich in der .env stehen müssen. Hinterlegt ist dort ein API-Token oder – wie bei
|
||||
* sshkeyvault – das Konto-Passwort; Gitea nimmt beides an, das Passwort aber nur per Basic-Auth
|
||||
* (#030).
|
||||
*/
|
||||
function tokenAusGitCredentials(basis: string): string | null {
|
||||
function zugangAusGitCredentials(basis: string): { benutzer: string; geheimnis: string } | null {
|
||||
const host = new URL(basis).host;
|
||||
|
||||
// Erst die eingerichteten Helfer (z. B. Schlüsselbund), dann zusätzlich die Datei
|
||||
@@ -167,7 +170,8 @@ function tokenAusGitCredentials(basis: string): string | null {
|
||||
stdio: ["pipe", "pipe", "ignore"],
|
||||
});
|
||||
const passwort = /^password=(.*)$/m.exec(antwort);
|
||||
if (passwort?.[1]) return passwort[1];
|
||||
const benutzer = /^username=(.*)$/m.exec(antwort);
|
||||
if (passwort?.[1]) return { benutzer: benutzer?.[1] ?? "", geheimnis: passwort[1] };
|
||||
} catch {
|
||||
// Nächste Möglichkeit versuchen.
|
||||
}
|
||||
@@ -186,8 +190,14 @@ async function main() {
|
||||
const remote = ausRemote();
|
||||
const basis = (process.env.GITEA_URL ?? remote?.basis ?? "").replace(/\/$/, "");
|
||||
const repo = process.env.GITEA_REPO ?? remote?.repo ?? "";
|
||||
const token = process.env.GITEA_TOKEN || (basis ? (tokenAusGitCredentials(basis) ?? "") : "");
|
||||
const zugang = process.env.GITEA_TOKEN ? null : basis ? zugangAusGitCredentials(basis) : null;
|
||||
const token = process.env.GITEA_TOKEN || zugang?.geheimnis || "";
|
||||
const tokenQuelle = process.env.GITEA_TOKEN ? "Umgebungsvariable" : "Git-Zugangsdaten";
|
||||
// Ein Gitea-Token sind 40 Hex-Zeichen; alles andere ist ein Passwort und geht per Basic-Auth.
|
||||
const autorisierung =
|
||||
process.env.GITEA_TOKEN || /^[0-9a-f]{40}$/.test(token) || !zugang?.benutzer
|
||||
? `token ${token}`
|
||||
: `Basic ${Buffer.from(`${zugang.benutzer}:${token}`).toString("base64")}`;
|
||||
|
||||
const verzeichnis = path.join(process.cwd(), "docs", "issues");
|
||||
const issues = await dateienLesen(verzeichnis);
|
||||
@@ -215,7 +225,7 @@ async function main() {
|
||||
}
|
||||
|
||||
console.log(`Ziel: ${basis}/${repo} (Token aus: ${tokenQuelle})`);
|
||||
const gitea = new Gitea(basis, repo, token);
|
||||
const gitea = new Gitea(basis, repo, autorisierung);
|
||||
|
||||
const vorhandeneLabels = new Map((await gitea.labels()).map((l) => [l.name.toLowerCase(), l.id]));
|
||||
const benoetigteLabels = [...new Set(issues.flatMap((issue) => issue.labels))];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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] },
|
||||
});
|
||||
}
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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.");
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,8 @@ export async function vorgangLaufen(
|
||||
| "VERBINDUNGSTEST"
|
||||
| "ZIELKONTO_ANLEGEN"
|
||||
| "ZIELKONTO_ENTFERNEN"
|
||||
| "ABGLEICH",
|
||||
| "ABGLEICH"
|
||||
| "ROTATION",
|
||||
daten: {
|
||||
hostId?: string;
|
||||
eingabe?: object;
|
||||
|
||||
Reference in New Issue
Block a user