12 Commits
Author SHA1 Message Date
florian.wilde c91e882159 Merge branch 'phase/032-abschluss-doku' 2026-10-12 18:08:05 +02:00
florian.wildeandClaude Opus 5.5 d34f8b6f54 docs(devlog): Sitzung 6, #031 und #032 nachgetragen (#032)
Zeiten und Entscheidungen der Sitzung festhalten, damit die Abnahme
von 1.0.0 den vollständigen Stand sieht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:08:03 +02:00
florian.wilde 844161dc1b Merge branch 'phase/032-nacharbeiten' 2026-10-12 17:56:33 +02:00
florian.wildeandClaude Opus 5.5 021e415fde docs(issues): #032 abgeschlossen, Entscheidungen notiert (#032)
Haelt fest, was die Nacharbeiten gefunden und entschieden haben:
den Fehler im Massenwiderruf (UUIDs fielen aus der Auswahl), das
Zustandsmodell des Rotationsabbruchs ohne neues Feld, warum es danach
keinen zweiten Nachfolger gibt, und wie die Protokoll-IDs ohne
Umnummerieren lueckenlos werden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:55:46 +02:00
florian.wildeandClaude Opus 5.5 f18fd4ec06 feat(rotation): laufende Rotation abbrechen (#032)
Bisher liess sich eine angefangene Rotation nur zu Ende fuehren. Wer
merkt, dass der neue Schluessel nicht passt, braucht einen Weg zurueck,
ohne den alten anzufassen.

"Rotation abbrechen" (Rotationsseite und Seite des Nachfolgers) merkt
den neuen Schluessel ueber den bestehenden Widerruf aus #024 vor; er
kann schon ausgerollt sein, deshalb folgen Dry-Run und Anwenden wie
sonst. Der alte bleibt aktiv und unveraendert. Wo beide denselben
Alias tragen, zeigt die config danach wieder auf die alte Datei, weil
privat.ts nur die verbleibenden Zuweisungen in den Block schreibt.

Zustandsmodell: kein neues Feld. Ein Nachfolger im Widerruf (oder
widerrufen) ist eine abgebrochene Rotation; vorgaengerId bleibt fuer
die Historie, der Abbruch steht als schluessel.rotation.abgebrochen im
Protokoll (in derselben Transaktion wie das Vormerken, dafuer nimmt
widerrufEinleiten optional einen Zusatzschritt an). Abbrechen geht nur,
solange der alte Schluessel noch nicht entfernt wird. widerrufAbschliessen
macht aus dem Widerruf des alten Schluessels kein ROTIERT mehr, wenn
der Nachfolger selbst im Widerruf ist. Weil vorgaengerId eindeutig
ist, gibt es nach einem Abbruch keinen zweiten Nachfolger; Seite und
Fehlermeldung sagen das.

Tests: DB-Tests fuer Abbruch, Idempotenz, Sperren und Status;
Integrationstest gegen echte Ziele (neuer Key ausgerollt, abbrechen,
neuer weg, alter in authorized_keys, ssh zu-b von A geht); E2E fuer
den Ablauf im Browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:45:11 +02:00
florian.wildeandClaude Opus 5.5 fb56fd36c5 fix(widerruf): Massenwiderruf nimmt erzeugte Schluessel an (#032)
Der neue E2E-Test (zwei im Browser erzeugte Schluessel mit Public Key
auf Ziel B, Auswahl in der Liste, frische Bestaetigung ueber den
Mock-OIDC mit auth_time, gemeinsamer Dry-Run, Anwenden) fand zwei
Fehler im Produkt:

- Die Massenwiderruf-Seite filterte die IDs aus ?id= mit
  /^[a-z0-9]{1,64}$/. Vom Worker erzeugte Schluessel tragen aber eine
  UUID mit Bindestrichen, nur Prisma-Defaults sind cuids. Damit fiel
  jeder erzeugte Schluessel still aus der Auswahl, die Seite meldete
  "Keine Schluessel gewaehlt". Der Filter steckt jetzt als
  idsAusSuchparameter in src/lib/schluessel.ts, erlaubt Bindestriche
  und hat einen eigenen Test.
- Der Hinweis nach verweigerter Bestaetigung sagte "erneut Widerruf
  vorbereiten waehlen", der Knopf heisst beim Massenwiderruf aber
  "2 Schluessel widerrufen". Er nennt jetzt den Knopf, den es gibt.

Der Test prueft danach, dass beide Keys aus authorized_keys auf B
verschwunden und WIDERRUFEN sind. datenbank() und oidcBestaetigen()
wandern dafuer aus schluessel.spec.ts nach tests/e2e/hilfen.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:44:46 +02:00
florian.wildeandClaude Opus 5.5 be9837ba50 feat(zuweisungen): Zum Dry-Run springt zur Dry-Run-Karte (#032)
Der Link im Zell-Dialog der Zuweisungsmatrix fuehrte nur auf den Kopf
der Schluesselseite; die Dry-Run-Karte liegt weit unten, und wer aus der
Matrix kommt, will genau dorthin. Die Karte traegt jetzt id="dry-run"
(Karte nimmt dafuer eine optionale id an), der Link zeigt auf
/schluessel/<id>#dry-run. scroll-mt haelt die Karte unter der festen
Kopfleiste der schmalen Ansicht.

E2E: der Matrix-Test klickt den Link und prueft Adresse und Sichtbarkeit
der Karte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:29:49 +02:00
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
florian.wildeandClaude Opus 5.5 b4804390c4 fix(protokoll): IDs ohne Zweierschritte vergeben (#032)
Spalten-Default (BIGSERIAL) und Trigger protokoll_verketten zogen je
eine Nummer aus der Sequenz, jede Zeile verbrauchte zwei IDs. Die
Nummer aus dem Default darf der Trigger aber nicht einfach
uebernehmen, denn sie wird vor der Advisory-Sperre gezogen: Eine parallele
Transaktion mit groesserer ID koennte zuerst verketten, und die Kette
liesse sich in ID-Reihenfolge nicht mehr pruefen.

Deshalb zieht nur noch der Trigger unter der Sperre; der Default ist
ein Platzhalter 0 (Prisma: @default(0)), damit create ohne ID
funktioniert. Die Sequenz bleibt der Spalte zugeordnet. Bestehende
Zeilen werden nicht umnummeriert, weil die ID in die Hash-Eingabe
eingeht.

Tests: drei Eintraege nacheinander ergeben n, n+1, n+2; eine
mitgegebene ID wird ueberschrieben; 20 parallele Eintraege belegen
genau 20 aufeinanderfolgende IDs bei intakter Kette.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:19:28 +02:00
florian.wilde 75077f9101 Merge branch 'phase/030-gitea-abgleich-2' 2026-10-12 17:13:18 +02:00
florian.wildeandClaude Opus 5.5 87b733cc9f chore(gitea): Issues nach Gitea, Basic-Auth fuer Konto-Passwort (#030)
In den Git-Zugangsdaten steht das Konto-Passwort, kein API-Token. Gitea
nimmt es nur per Basic-Auth an; das Skript erkennt das jetzt selbst.
Alle 30 Issues mit Labels und Meilensteinen uebertragen. Neu: #031
Rotation des Deployment-Keys und #032 Nacharbeiten fuer 1.0.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:13:15 +02:00
48 changed files with 1763 additions and 131 deletions
+6
View File
@@ -8,10 +8,16 @@ 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)
- Laufende Schlüsselrotation abbrechen – der Nachfolger wird per Dry-Run wieder entfernt (#032)
- „Zum Dry-Run“ in der Zuweisungsmatrix springt direkt zur Dry-Run-Karte (#032)
### Geändert
- Protokoll-IDs werden lückenlos vergeben (#032)
### Behoben
- Massenwiderruf lehnte im Browser erzeugte Schlüssel ab (IDs mit Bindestrich) (#032)
- Hinweis nach verweigerter Bestätigung nannte beim Massenwiderruf den falschen Knopf (#032)
### Sicherheit
+23 -3
View File
@@ -2,8 +2,8 @@
## Zeitübersicht
**Gesamt (Wandzeit):** 4 h 25 min
**Summe der Issue-Zeiten:** 13 h 18 min – Sub-Agenten arbeiteten parallel an #002, #006, #008, #011, #012–#014, #019 (Oberfläche), #023–#025, #027 und #028.
**Gesamt (Wandzeit):** 5 h 20 min
**Summe der Issue-Zeiten:** 15 h 13 min – Sub-Agenten arbeiteten parallel an #002, #006, #008, #011, #012–#014, #019 (Oberfläche), #023–#025, #027, #028 und #032.
| Issue | Zeit |
|---|---|
@@ -37,7 +37,9 @@
| #027 Protokollansicht, CSV, Syslog | 0 h 20 min |
| #028 Benachrichtigungen | 0 h 30 min |
| #029 Handbuch und Betriebsdokumentation | 0 h 50 min |
| #030 Gitea-Abgleich (vorbereitet) | 0 h 05 min |
| #030 Gitea-Abgleich | 0 h 05 min |
| #031 Rotation des Deployment-Keys | 1 h 10 min |
| #032 Nacharbeiten Phase 4/5 | 0 h 45 min |
| Meilenstein | Zeit |
|---|---|
@@ -46,6 +48,7 @@
| v0.3.0 | 0 h 30 min (Wandzeit) | **abgeschlossen**
| v0.4.0 | 0 h 45 min (Wandzeit) | **abgeschlossen**
| v0.5.0 | 0 h 45 min (Wandzeit) | **abgeschlossen**
| v1.0.0 | 0 h 55 min (Wandzeit) | in Arbeit – Abnahme steht aus
---
@@ -149,3 +152,20 @@
- **Master-Key nicht in die Sicherung,** nur ein Prüfwert; Worker verweigert den Start mit falschem Master-Key.
- **Benachrichtigungen lesen ihre Anlässe aus der Datenbank** statt sich in den Abgleich einzuhängen; Doppelmeldungen verhindert ein Versandprotokoll mit Fingerabdruck je Anlass.
- **Keine 1.0.0 ohne Abnahme:** 0.5.0 schließt die fünf Phasen; authentik, Gitea und ein Lauf auf echten Hosts stehen noch aus.
## 2026-10-12 · Sitzung 6 · 17:12–18:07
**Issue:** #030–#032
**Version:** unveröffentlicht (auf dem Weg zu 1.0.0)
### Erledigt
- Remote-Repo in Gitea: `main` und Tags v0.1.0–v0.5.0 gepusht, Issues abgeglichen (Basic-Auth, weil die hinterlegten Zugangsdaten ein Passwort sind)
- #031 Geführte Rotation des Deployment-Keys: Service, Worker-Schritte, Seite mit Schrittleiste, Integrationstest gegen beide Testziele, E2E
- #032 Nacharbeiten (Sub-Agent): lückenlose Protokoll-IDs, Rotation abbrechen, Anker „Zum Dry-Run“, Massenwiderruf-E2E – der fand, dass der Massenwiderruf UUIDs mit Bindestrich abwies
- Stand: 876 Unit-/DB-Tests, 46 Integrationstests, 27 E2E-Abläufe grün
### Entscheidungen
- **Der Nachfolger des Deployment-Keys ist ein eigener Schlüssel,** der während der Rotation über `sollFuer()` in Block und Pflichtliste steht – so verteilt ihn der normale Dry-Run, und der Aussperrschutz deckt beide Keys.
- **Umstellen erst nach Prüfung** auf allen Hosts; ein Host lässt sich nur bewusst und protokolliert übergehen.
- **Rotation abbrechen nutzt den vorhandenen Widerruf** statt eines neuen Zustands (#032).
- **Protokoll-ID nur noch aus dem Trigger** unter der Advisory-Sperre – keine Lücken mehr durch verworfene Sequenzwerte.
+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
+6 -4
View File
@@ -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.
+30
View File
@@ -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.
+52
View File
@@ -0,0 +1,52 @@
# #032 Nacharbeiten aus Phase 4/5
**Status:** erledigt
**Meilenstein:** v1.0.0
**Labels:** test, chore
**Geschätzt:** 2 h **Tatsächlich:** 0,75 h
## Ziel
Kleinere offene Punkte aus den Berichten der Phasen 4 und 5 erledigen.
## Aufgaben
- [x] Massenwiderruf im Browser testen (E2E mit frischer Bestätigung über den Mock-OIDC)
- [x] Laufende Rotation abbrechen können (Nachfolger widerrufen, Vorgänger wieder aktiv)
- [x] Protokoll-IDs ohne Zweierschritte (Spalten-Default und Trigger ziehen beide `nextval`)
- [x] „Zum Dry-Run“ aus der Zuweisungsmatrix direkt zur Dry-Run-Karte springen (Anker)
## Akzeptanzkriterien
- [x] Alle Suiten grün
## Abhängigkeiten
#023, #024, #025, #027
## Notizen / Entscheidungen
- **Massenwiderruf:** Der neue E2E-Test (`widerruf.spec.ts`) fand einen echten Fehler: Die Seite
`/schluessel/widerruf` filterte `?id=` mit `/^[a-z0-9]{1,64}$/`. Vom Worker erzeugte Schlüssel tragen
eine UUID mit Bindestrichen – jeder erzeugte Schlüssel fiel still heraus („Keine Schlüssel gewählt“).
Jetzt `idsAusSuchparameter` in `src/lib/schluessel.ts` (mit Test). Außerdem nannte der Hinweis nach
verweigerter Bestätigung den falschen Knopf („Widerruf vorbereiten“ statt „2 Schlüssel widerrufen“).
`datenbank()` und `oidcBestaetigen()` liegen jetzt in `tests/e2e/hilfen.ts`.
- **Rotation abbrechen:** kein neues Feld. Abbrechen = Widerruf des Nachfolgers über den Ablauf aus #024
(Dry-Run, er kann schon ausgerollt sein); ein Nachfolger im Widerruf bzw. widerrufen *ist* die
abgebrochene Rotation (`rotationAbgebrochen`). `vorgaengerId` bleibt für die Historie, der Abbruch
steht als `schluessel.rotation.abgebrochen` im Protokoll – in derselben Transaktion wie das Vormerken
(`widerrufEinleiten` nimmt dafür optional `zusaetzlich` an). Der alte Schlüssel bleibt unverändert; bei
gleichem Alias zeigt die config nach dem Anwenden wieder auf die alte Datei (nur verbleibende
Zuweisungen landen im Block).
- Abbrechen geht nur, solange der alte Schlüssel noch nicht entfernt wird (Schritt 5) – sonst wären am
Ende beide halb entfernt. Auf dem Nachfolger ersetzt „Rotation abbrechen“ den normalen
„Widerrufen“-Knopf, damit der Abbruch immer im Protokoll steht.
- `widerrufAbschliessen` setzt `ROTIERT` nur noch bei einem aktiven Nachfolger, der nicht selbst im
Widerruf ist; die Seite schreibt „Ersetzt durch …“ nur bei `ROTIERT`.
- **Kein zweiter Nachfolger:** `vorgaengerId` ist eindeutig (1:1-Relation). Nach einem Abbruch lässt sich
derselbe Schlüssel deshalb nicht erneut über sshkv rotieren; Seite und Fehlermeldung sagen das
(neuen Schlüssel anlegen, zuweisen, alten widerrufen). Eine erneute Rotation bräuchte eine
1:n-Relation mit eindeutigem Teilindex „ein nicht widerrufener Nachfolger je Vorgänger“.
- **Protokoll-IDs:** Migration `20261012173000_protokoll_id_lueckenlos` setzt den Spalten-Default auf den
Platzhalter `0` (Prisma: `@default(0)`, `create` ohne ID geht weiter); die ID zieht nur noch der
Trigger unter der Advisory-Sperre aus der weiterhin zugeordneten Sequenz. Bestehende Zeilen bleiben
unverändert (die ID steckt in der Hash-Eingabe), alte Lücken bleiben also stehen. Eine mitgegebene ID
überschreibt der Trigger weiterhin.
- **Anker:** Die Dry-Run-Karte trägt `id="dry-run"` (`Karte` nimmt eine optionale `id`) und
`scroll-mt-20` wegen der festen Kopfleiste der schmalen Ansicht; „Zum Dry-Run“ zeigt auf
`/schluessel/<id>#dry-run`.
@@ -0,0 +1,15 @@
-- #032: Protokoll-IDs ohne Zweierschritte.
--
-- Bisher zog der Spalten-Default (BIGSERIAL) eine Nummer aus der Sequenz und der Trigger
-- `protokoll_verketten` gleich noch eine – jede Zeile verbrauchte zwei IDs. Die ID muss aber
-- im Trigger unter der Advisory-Sperre vergeben werden: Eine vorher gezogene Nummer koennte
-- kleiner sein als die einer parallelen Transaktion, die zuerst verkettet, und die Kette liesse
-- sich in ID-Reihenfolge nicht mehr pruefen. Deshalb verzichtet der Default auf die Sequenz.
--
-- 0 ist nur ein Platzhalter, damit `prisma.protokoll.create` ohne ID auskommt; der Trigger
-- ueberschreibt jede ID (auch eine mitgegebene). Die Sequenz bleibt der Spalte zugeordnet
-- (OWNED BY), deshalb findet `pg_get_serial_sequence` sie im Trigger weiterhin.
--
-- Bestehende Zeilen bleiben, wie sie sind: Die ID ist Teil der Hash-Eingabe, Umnummerieren
-- wuerde die Kette brechen. Die alten Luecken bleiben also stehen.
ALTER TABLE "Protokoll" ALTER COLUMN "id" SET DEFAULT 0;
+3 -1
View File
@@ -483,7 +483,9 @@ model Vorgang {
/// Revisionssicheres Protokoll (#005). Append-only per Trigger; `hash` verkettet jede Zeile
/// mit ihrer Vorgaengerin und wird in der Datenbank berechnet.
model Protokoll {
id BigInt @id @default(autoincrement())
/// Vergibt der Trigger `protokoll_verketten` unter der Advisory-Sperre aus der Sequenz; der
/// Default 0 ist nur ein Platzhalter, damit `create` ohne ID auskommt (#032).
id BigInt @id @default(0)
zeitpunkt DateTime @default(now())
akteurId String?
/// Name zum Zeitpunkt der Aktion – bleibt lesbar, auch wenn das Konto spaeter umbenannt wird.
+18 -8
View File
@@ -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))];
+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>
)}
+72 -23
View File
@@ -23,7 +23,8 @@ import { widerrufUebersicht } from "@/server/queries/widerruf";
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
import { WiderrufKnopf } from "@/components/widerruf/WiderrufKnopf";
import { ablaufZustand } from "@/lib/schluessel";
import { rotationGesperrt } from "@/server/services/rotation";
import { RotationAbbrechen } from "@/components/rotation/RotationAbbrechen";
import { rotationAbgebrochen, rotationGesperrt } from "@/server/services/rotation";
import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion";
import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen";
import { PublicAusrollenAktion } from "./PublicAusrollenAktion";
@@ -216,9 +217,17 @@ export default async function SchluesselSeite({
const jetzt = new Date();
const abgelaufen = s.status === "ABGELAUFEN" || ablaufZustand(s.ablaufAm, jetzt).art === "abgelaufen";
const verwaltenRecht = darf(benutzer.rolle, "schluesselVerwalten");
// Auf dem Nachfolger: die laufende Rotation gehoert zum Vorgaenger.
const rotationDesVorgaengers =
s.vorgaenger && (s.vorgaenger.status === "AKTIV" || s.vorgaenger.status === "ABGELAUFEN")
? s.vorgaenger
: null;
const widerrufbar =
!imWiderruf &&
!(s.nachfolger?.status === "AKTIV") &&
// Ein Nachfolger mitten in der Rotation wird ueber „Rotation abbrechen“ widerrufen (#032) –
// so steht der Abbruch im Protokoll und die Rotationsseite kennt den Stand.
!rotationDesVorgaengers &&
s.kategorie !== "DEPLOYMENT" &&
(s.status === "AKTIV" || s.status === "ABGELAUFEN") &&
rechte.public &&
@@ -235,13 +244,19 @@ export default async function SchluesselSeite({
privatZuweisungen: aktivePrivat,
});
const rotationLaeuft = s.nachfolger?.status === "AKTIV" && s.status !== "ROTIERT";
const abgebrocheneRotation = s.status !== "ROTIERT" && rotationAbgebrochen(s.nachfolger);
const darfRotieren = verwaltenRecht && rechte.public && (aktivePrivat === 0 || rechte.privat);
const rotierbar = darfRotieren && (rotationLaeuft || (!rotationsSperre && !imWiderruf));
// Auf dem Nachfolger: die laufende Rotation gehoert zum Vorgaenger.
const rotationDesVorgaengers =
s.vorgaenger && (s.vorgaenger.status === "AKTIV" || s.vorgaenger.status === "ABGELAUFEN")
? s.vorgaenger
: null;
// Nach einem abgeschlossenen Abbruch (#032) bleibt der Nachfolger in der Kette; ein zweiter
// passt nicht hinein (vorgaengerId ist eindeutig) – deshalb kein „Rotieren“ mehr.
const rotierbar = darfRotieren && (rotationLaeuft || (!rotationsSperre && !imWiderruf && !s.nachfolger));
// Abbrechen auf dem Nachfolger: gleiche Rechte wie die Rotation, Private Keys nur mit Recht.
const darfAbbrechen =
Boolean(rotationDesVorgaengers && !rotationDesVorgaengers.widerrufEingeleitetAm) &&
!imWiderruf &&
s.status === "AKTIV" &&
verwaltenRecht &&
rechte.public &&
(s.privateVerwendungen.length === 0 || rechte.privat);
const vorauswahl = (
Array.isArray(zielkontenParameter) ? zielkontenParameter.join(",") : (zielkontenParameter ?? "")
)
@@ -308,20 +323,41 @@ export default async function SchluesselSeite({
}
/>
{rotationDesVorgaengers && (
<Karte titel="Rotation läuft" className="border-accent">
<p className="text-muted text-[14px]">
Dieser Schlüssel löst „{rotationDesVorgaengers.name}“ ab. Die Rotation ist erst fertig, wenn der
neue Schlüssel ausgerollt und getestet und der alte entfernt ist.{" "}
<Link
href={`/schluessel/${rotationDesVorgaengers.id}/rotieren`}
className="text-accent hover:underline"
>
Rotation fortsetzen
</Link>
</p>
</Karte>
)}
{rotationDesVorgaengers &&
(imWiderruf || s.status === "WIDERRUFEN" ? (
<Karte titel="Rotation abgebrochen">
<p className="text-muted text-[14px]">
Dieser Schlüssel sollte „{rotationDesVorgaengers.name}“ ablösen; die Rotation wurde abgebrochen.
{imWiderruf ? " Er wird gerade entfernt; " : " Er ist von allen Hosts entfernt; "}
<Link href={`/schluessel/${rotationDesVorgaengers.id}`} className="text-accent hover:underline">
{rotationDesVorgaengers.name}
</Link>{" "}
bleibt aktiv.
</p>
</Karte>
) : (
<Karte titel="Rotation läuft" className="border-accent">
<p className="text-muted text-[14px]">
Dieser Schlüssel löst „{rotationDesVorgaengers.name}“ ab. Die Rotation ist erst fertig, wenn der
neue Schlüssel ausgerollt und getestet und der alte entfernt ist.{" "}
<Link
href={`/schluessel/${rotationDesVorgaengers.id}/rotieren`}
className="text-accent hover:underline"
>
Rotation fortsetzen
</Link>
</p>
{darfAbbrechen && (
<div className="mt-4">
<RotationAbbrechen
altId={rotationDesVorgaengers.id}
altName={rotationDesVorgaengers.name}
neuName={s.name}
/>
</div>
)}
</Karte>
))}
{imWiderruf && widerruf && (
<Karte
@@ -336,7 +372,7 @@ export default async function SchluesselSeite({
vorauswahl={vorauswahl}
ruecksprung={`/schluessel/${s.id}`}
darfPrivat={rechte.privat}
ohneZielkonten={rotationLaeuft}
ohneZielkonten={rotationLaeuft || Boolean(rotationDesVorgaengers)}
/>
</Karte>
)}
@@ -367,7 +403,8 @@ export default async function SchluesselSeite({
<Karte titel={s.status === "WIDERRUFEN" ? "Widerrufen" : "Rotiert"}>
<p className="text-muted text-[14px]">
Von allen Hosts entfernt; der Schlüssel lässt sich nicht mehr zuweisen.
{s.nachfolger && (
{/* Ein widerrufener Nachfolger (abgebrochene Rotation, #032) ersetzt nichts. */}
{s.nachfolger && s.status === "ROTIERT" && (
<>
{" "}
Ersetzt durch{" "}
@@ -457,6 +494,10 @@ export default async function SchluesselSeite({
!imWiderruf &&
(s.publicVerwendungen.length > 0 || (rechte.privat && s.privateVerwendungen.length > 0)) && (
<Karte
// Sprungziel fuer „Zum Dry-Run“ aus der Zuweisungsmatrix; scroll-mt haelt die Karte
// unter der festen Kopfleiste der schmalen Ansicht (#032).
id="dry-run"
className="scroll-mt-20"
titel="Dry-Run-Vorschau"
untertitel="Erst prüfen, dann anwenden – auf den Hosts ändert sich vorher nichts"
>
@@ -498,6 +539,14 @@ export default async function SchluesselSeite({
))}
</ol>
)}
{abgebrocheneRotation && s.nachfolger && (
<p className="text-muted mt-3 text-[14px]">
Die Rotation zu „{s.nachfolger.name}“ wurde abgebrochen
{s.nachfolger.status === "WIDERRUFEN" ? "" : " – der neue Schlüssel wird gerade entfernt"};
dieser Schlüssel bleibt aktiv. Ein zweiter Nachfolger ist nicht vorgesehen: für eine neue
Rotation einen neuen Schlüssel anlegen, genauso zuweisen und diesen danach widerrufen.
</p>
)}
{rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf && (
<p className={`text-muted text-[14px] ${kette.length > 1 ? "mt-3" : ""}`}>
{rotationsSperre.grund}
@@ -59,9 +59,14 @@ export default async function RotierenSeite({ params }: { params: Promise<{ id:
}
const widerruf =
stand.neu && alt.widerrufEingeleitetAm && alt.status !== "ROTIERT"
stand.neu && !stand.abgebrochen && alt.widerrufEingeleitetAm && alt.status !== "ROTIERT"
? await offeneEntfernungen([alt.id])
: null;
// Abbruch (#032): solange der neue Schluessel nicht widerrufen ist, laeuft sein Widerruf hier weiter.
const abbruch =
stand.neu && stand.abgebrochen
? { offen: stand.neu.status === "WIDERRUFEN" ? null : await offeneEntfernungen([stand.neu.id]) }
: null;
return (
<div className="flex flex-col gap-6 sm:gap-7">
@@ -74,6 +79,7 @@ export default async function RotierenSeite({ params }: { params: Promise<{ id:
neuOffen: stand.neu ? stand.neuOffen : 0,
tests: stand.neu ? stand.tests : [],
widerruf,
abbruch,
darfPrivat: darf(benutzer.rolle, "privateKeyAusrollen"),
}}
/>
+3 -10
View File
@@ -9,6 +9,7 @@ import { Leerzustand } from "@/components/ui/Leerzustand";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
import { WiderrufFormular } from "@/components/widerruf/WiderrufFormular";
import { idsAusSuchparameter } from "@/lib/schluessel";
import { darf } from "@/server/auth/rechte";
import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { widerrufUebersicht } from "@/server/queries/widerruf";
@@ -17,14 +18,6 @@ export const metadata: Metadata = { title: "Massenwiderruf" };
type Suchparameter = Promise<{ id?: string | string[]; zielkonten?: string | string[] }>;
function liste(wert: string | string[] | undefined): string[] {
const roh = Array.isArray(wert) ? wert : wert ? [wert] : [];
return [...new Set(roh.flatMap((w) => w.split(",")).filter((w) => /^[a-z0-9]{1,64}$/i.test(w)))].slice(
0,
200,
);
}
/**
* Massenwiderruf (#024): mehrere Schluessel – etwa alle einer Person nach Projektende – in einem
* Durchgang mit gemeinsamem Dry-Run. Nur Admins; das Vormerken verlangt eine frische
@@ -32,7 +25,7 @@ function liste(wert: string | string[] | undefined): string[] {
*/
export default async function MassenwiderrufSeite({ searchParams }: { searchParams: Suchparameter }) {
const parameter = await searchParams;
const ids = liste(parameter.id);
const ids = idsAusSuchparameter(parameter.id);
const selbst = `/schluessel/widerruf?${ids.map((id) => `id=${id}`).join("&")}`;
const { benutzer } = await sitzungErforderlich("massenwiderruf", selbst);
@@ -61,7 +54,7 @@ export default async function MassenwiderrufSeite({ searchParams }: { searchPara
const offeneStati = (s: { status: string }) => s.status === "AKTIV" || s.status === "ABGELAUFEN";
const nochNichtEingeleitet = uebersicht.schluessel.filter((s) => offeneStati(s) && !s.eingeleitet);
const laufend = uebersicht.schluessel.filter((s) => offeneStati(s) && s.eingeleitet);
const vorauswahl = liste(parameter.zielkonten);
const vorauswahl = idsAusSuchparameter(parameter.zielkonten);
return (
<div className="flex flex-col gap-6 sm:gap-7">
@@ -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>
);
}
+45
View File
@@ -9,6 +9,7 @@ import { Button, Formularfehler, Karte } from "@/components/ui";
import { Schrittleiste } from "@/components/ui/Schrittleiste";
import { warten } from "@/components/vorgang/warten";
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
import { RotationAbbrechen } from "./RotationAbbrechen";
import {
rotationAltWiderrufenAktion,
rotationStartenAktion,
@@ -47,6 +48,11 @@ export type RotationsDaten = {
/** Offene Entfernungen des alten Schluessels, sobald sein Widerruf laeuft. */
widerruf: OffeneEntfernung[] | null;
darfPrivat: boolean;
/**
* Abgebrochene Rotation (#032): `offen` sind die offenen Entfernungen des neuen Schluessels,
* solange sein Widerruf laeuft; null, wenn er abgeschlossen ist.
*/
abbruch: { offen: OffeneEntfernung[] | null } | null;
};
export function Rotation({ daten }: { daten: RotationsDaten }) {
@@ -109,6 +115,40 @@ export function Rotation({ daten }: { daten: RotationsDaten }) {
</>
);
if (daten.abbruch && neu) {
return daten.abbruch.offen ? (
<Karte
titel="Rotation wird abgebrochen"
untertitel={`Der neue Schlüssel wird entfernt – „${alt.name}“ bleibt aktiv und unverändert.`}
className="border-danger-border"
>
<WiderrufAblauf
schluesselIds={[neu.id]}
offen={daten.abbruch.offen}
zielkonten={[]}
vorauswahl={[]}
ruecksprung={`/schluessel/${alt.id}/rotieren`}
darfPrivat={daten.darfPrivat}
ohneZielkonten
/>
</Karte>
) : (
<Karte titel="Rotation abgebrochen">
<p className="text-[14px]">
Der neue Schlüssel{" "}
<Link href={`/schluessel/${neu.id}`} className="text-accent hover:underline">
{neu.name}
</Link>{" "}
ist widerrufen und von allen Hosts entfernt.{" "}
<Link href={`/schluessel/${alt.id}`} className="text-accent hover:underline">
{alt.name}
</Link>{" "}
bleibt aktiv.
</p>
</Karte>
);
}
return (
<div className="flex flex-col gap-6">
<Schrittleiste schritte={SCHRITTE} aktuell={schritt} beschriftung="Schritte der Rotation" />
@@ -225,6 +265,11 @@ export function Rotation({ daten }: { daten: RotationsDaten }) {
</Karte>
)}
{/* Abbrechen geht, solange der alte Schluessel noch nicht entfernt wird (#032). */}
{neu && schritt >= 1 && schritt <= 4 && !daten.widerruf && (
<RotationAbbrechen altId={alt.id} altName={alt.name} neuName={neu.name} />
)}
{schritt === 5 && neu && (
<Karte titel="Rotation abgeschlossen">
<p className="text-[14px]">
@@ -0,0 +1,80 @@
"use client";
import { Undo2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button, Formularfehler } from "@/components/ui";
import { rotationAbbrechenAktion } from "@/server/actions/rotation";
/**
* „Rotation abbrechen“ (#032) – auf der Rotationsseite und auf der Seite des Nachfolgers. Der
* Klick merkt den neuen Schluessel nur zum Widerruf vor; entfernt wird er erst nach Dry-Run und
* „Anwenden“. Deshalb erst eine Rueckfrage, die genau das sagt.
*/
export function RotationAbbrechen({
altId,
altName,
neuName,
}: {
altId: string;
altName: string;
neuName: string;
}) {
const router = useRouter();
const [offen, setOffen] = useState(false);
const [fehler, setFehler] = useState<string>();
const [laeuft, starten] = useTransition();
if (!offen) {
return (
<div>
<Button icon={<Undo2 className="size-4" />} onClick={() => setOffen(true)}>
Rotation abbrechen
</Button>
</div>
);
}
return (
<div
role="group"
aria-label="Rotation abbrechen?"
className="border-danger-border bg-bg rounded-box flex flex-col gap-3 border px-4 py-3 text-[14px]"
>
<p className="font-medium">Rotation abbrechen?</p>
<ul className="text-muted list-disc space-y-1 pl-5 text-[13.5px]">
<li>
Der neue Schlüssel „{neuName}“ wird widerrufen – wo er schon ausgerollt ist, zeigt der Dry-Run, was
entfernt wird.
</li>
<li>„{altName}“ bleibt aktiv und unverändert; ein config-Alias zeigt danach wieder auf ihn.</li>
<li>Die Verknüpfung bleibt in der Rotationskette sichtbar, der Abbruch steht im Protokoll.</li>
</ul>
<Formularfehler text={fehler} />
<div className="flex flex-wrap justify-end gap-2.5">
<Button disabled={laeuft} onClick={() => setOffen(false)}>
Zurück
</Button>
<Button
variante="gefahr"
icon={<Undo2 className="size-4" />}
disabled={laeuft}
onClick={() =>
starten(async () => {
setFehler(undefined);
const antwort = await rotationAbbrechenAktion(altId);
if (antwort.fehler) {
setFehler(antwort.fehler);
return;
}
setOffen(false);
router.refresh();
})
}
>
{laeuft ? "Wird vorgemerkt …" : "Neuen Schlüssel widerrufen"}
</Button>
</div>
</div>
);
}
+4
View File
@@ -13,6 +13,7 @@ export function Karte({
className,
innen = "normal",
as: Element = "section",
id,
}: {
titel?: ReactNode;
untertitel?: ReactNode;
@@ -22,9 +23,12 @@ export function Karte({
/** „ohne“ für Inhalte, die bis an den Rand reichen (z. B. Tabellen mit eigenem Abstand). */
innen?: "normal" | "ohne";
as?: "section" | "div" | "article";
/** Sprungziel für Links von anderen Seiten (z. B. `#dry-run`, #032). */
id?: string;
}) {
return (
<Element
id={id}
className={clsx(
"border-border bg-surface min-w-0 rounded-karte border",
innen === "normal" && "p-5 sm:p-6",
+4 -6
View File
@@ -32,6 +32,7 @@ export function WiderrufFormular({
const [laeuft, starten] = useTransition();
const gesperrt = uebersicht.schluessel.filter((s) => s.gesperrt);
const mehrere = uebersicht.schluessel.length > 1;
const knopfText = mehrere ? `${uebersicht.schluessel.length} Schlüssel widerrufen` : "Widerruf vorbereiten";
const ziel = (() => {
const url = new URL(ruecksprung, "http://x");
@@ -100,7 +101,8 @@ export function WiderrufFormular({
href={`/bestaetigen?ziel=${encodeURIComponent(ruecksprung)}`}
className="text-accent inline-flex min-h-11 items-center text-[14px] font-medium"
>
Anmeldung bestätigen und danach erneut „Widerruf vorbereiten“ wählen
{/* Nennt den Knopf, den es hier wirklich gibt – beim Massenwiderruf heisst er anders (#032). */}
Anmeldung bestätigen und danach erneut „{knopfText}“ wählen
</a>
)}
<div className="flex flex-wrap justify-end gap-2.5">
@@ -124,11 +126,7 @@ export function WiderrufFormular({
})
}
>
{laeuft
? "Wird vorgemerkt …"
: mehrere
? `${uebersicht.schluessel.length} Schlüssel widerrufen`
: "Widerruf vorbereiten"}
{laeuft ? "Wird vorgemerkt …" : knopfText}
</Button>
</div>
</div>
+1 -1
View File
@@ -175,7 +175,7 @@ export function ZellenDialog({
zeigt und schreibt die Änderung.
</p>
<Button
href={`/schluessel/${schluessel.id}`}
href={`/schluessel/${schluessel.id}#dry-run`}
klein
className="self-start"
icon={<ArrowRight className="size-4" />}
+4
View File
@@ -77,12 +77,16 @@ 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"),
"schluessel.widerrufen": a("Schlüssel widerrufen", "widerruf"),
"schluessel.rotiert": a("Schlüssel rotiert", "widerruf"),
"schluessel.rotation.uebernommen": a("Rotation: Zuweisungen übernommen", "widerruf"),
"schluessel.rotation.abgebrochen": a("Rotation abgebrochen", "widerruf"),
// Zielkonten (#018)
"zielkonto.angelegt": a("Zielkonto angelegt", "zielkonto"),
+11
View File
@@ -293,3 +293,14 @@ export function publicKeyZeileMitKommentar(zeile: string, kommentar: string): st
const [typ, base64] = zeile.trim().split(/\s+/);
return kommentar ? `${typ} ${base64} ${kommentar}` : `${typ} ${base64}`;
}
/**
* IDs aus einem Suchparameter (`?id=a&id=b` oder `?id=a,b`), etwa fuer den Massenwiderruf (#024).
* Erlaubt Bindestriche: Vom Worker erzeugte Schluessel tragen eine UUID, nur Prisma-Defaults sind
* cuids – ohne Bindestrich fielen alle erzeugten Schluessel still aus der Auswahl (#032).
*/
export function idsAusSuchparameter(wert: string | string[] | undefined, hoechstens = 200): string[] {
const roh = Array.isArray(wert) ? wert : wert ? [wert] : [];
const gueltig = roh.flatMap((w) => w.split(",")).filter((w) => /^[a-z0-9-]{1,64}$/i.test(w));
return [...new Set(gueltig)].slice(0, hoechstens);
}
+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);
}
}
+25 -1
View File
@@ -6,7 +6,12 @@ import { akteurVon } from "@/server/actions/formular";
import { darf } from "@/server/auth/rechte";
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { FachFehler } from "@/server/services/fachfehler";
import { rotationAltWiderrufen, rotationStarten, rotationUebernehmen } from "@/server/services/rotation";
import {
rotationAbbrechen,
rotationAltWiderrufen,
rotationStarten,
rotationUebernehmen,
} from "@/server/services/rotation";
import { VerwendungsFehler } from "@/server/services/verwendungen";
/**
@@ -69,3 +74,22 @@ export async function rotationAltWiderrufenAktion(schluesselId: string): Promise
return alsAntwort(fehler);
}
}
/**
* Laufende Rotation abbrechen (#032): merkt den neuen Schluessel zum Widerruf vor. Gleiche Rechte
* wie die Rotation selbst; Private Keys entfernt nur, wer sie ausrollen darf (Service).
*/
export async function rotationAbbrechenAktion(schluesselId: string): Promise<RotationsAntwort> {
try {
const { benutzer } = await berechtigt();
const altId = id.parse(schluesselId);
await rotationAbbrechen(altId, akteurVon(benutzer), {
privatErlaubt: darf(benutzer.rolle, "privateKeyAusrollen"),
});
revalidatePath(`/schluessel/${altId}`);
revalidatePath(`/schluessel/${altId}/rotieren`);
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 {
+3 -2
View File
@@ -58,8 +58,9 @@ export const SCHLUESSEL_DETAIL_SELECT = {
person: { select: { id: true, name: true } },
vorlage: { select: { id: true, name: true } },
erstelltVon: { select: { name: true } },
vorgaenger: { select: { id: true, name: true, status: true } },
nachfolger: { select: { id: true, name: true, status: true } },
vorgaenger: { select: { id: true, name: true, status: true, widerrufEingeleitetAm: true } },
// Ein Nachfolger im Widerruf ist eine abgebrochene Rotation (#032).
nachfolger: { select: { id: true, name: true, status: true, widerrufEingeleitetAm: true } },
publicVerwendungen: {
where: { zustand: { not: "ENTFERNT" } },
orderBy: { angelegtAm: "asc" },
+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] },
});
}
+66 -3
View File
@@ -45,7 +45,7 @@ const ALT_SELECT = {
vorlageId: true,
masterKeyVersion: true,
widerrufEingeleitetAm: true,
nachfolger: { select: { id: true, name: true, status: true } },
nachfolger: { select: { id: true, name: true, status: true, widerrufEingeleitetAm: true } },
_count: { select: { privateVerwendungen: { where: { zustand: { in: [...AKTIVE] } } } } },
} satisfies Prisma.SchluesselSelect;
@@ -92,6 +92,13 @@ function gesperrtPruefen(alt: Alt) {
export async function rotationStarten(altId: string, akteur: Akteur): Promise<string> {
const alt = await altLaden(altId);
gesperrtPruefen(alt);
if (alt.nachfolger && rotationAbgebrochen(alt.nachfolger)) {
// vorgaengerId ist eindeutig und bleibt fuer die Historie stehen (#032): ein zweiter
// Nachfolger passt nicht in die Kette.
throw new RotationsFehler(
"Die Rotation dieses Schlüssels wurde abgebrochen; ein zweiter Nachfolger ist nicht vorgesehen. Bitte einen neuen Schlüssel anlegen, genauso zuweisen und diesen danach widerrufen.",
);
}
if (alt.nachfolger) throw new RotationsFehler("Für diesen Schlüssel gibt es schon einen Nachfolger.");
const name = rotationsName(alt.name, rotationsDatum().anzeige, NAME_MAX);
const slug = await eindeutigerSlug(null, name);
@@ -111,8 +118,19 @@ export async function rotationStarten(altId: string, akteur: Akteur): Promise<st
return vorgangEinreihen({ art: "SCHLUESSEL_ERZEUGEN", angefordertVonId: akteur.id, eingabe });
}
/**
* Ein Nachfolger im Widerruf (oder widerrufen) heisst: die Rotation wurde abgebrochen (#032).
* Ein eigenes Feld braucht es dafuer nicht – der Widerruf des Nachfolgers ist der Abbruch.
*/
export function rotationAbgebrochen(
neu: { readonly status: string; readonly widerrufEingeleitetAm: Date | null } | null,
): boolean {
return neu !== null && (neu.status === "WIDERRUFEN" || neu.widerrufEingeleitetAm !== null);
}
async function nachfolgerVon(alt: Alt) {
if (!alt.nachfolger) throw new RotationsFehler("Erst den neuen Schlüssel erzeugen.");
if (rotationAbgebrochen(alt.nachfolger)) throw new RotationsFehler("Die Rotation wurde abgebrochen.");
if (alt.nachfolger.status !== "AKTIV") throw new RotationsFehler("Der neue Schlüssel ist nicht aktiv.");
return alt.nachfolger;
}
@@ -255,7 +273,8 @@ export async function rotationsStand(altId: string) {
const alt = await altLaden(altId);
const gesperrt = rotationGesperrt({ ...alt, privatZuweisungen: alt._count.privateVerwendungen });
const neu = alt.nachfolger;
if (!neu) return { alt, gesperrt, neu: null, schritt: 0 as const };
if (!neu) return { alt, gesperrt, neu: null, schritt: 0 as const, abgebrochen: false };
const abgebrochen = rotationAbgebrochen(neu);
const [altPublic, altPrivat, neuPublic, neuPrivat, tests] = await Promise.all([
prisma.publicVerwendung.count({ where: { schluesselId: alt.id, zustand: { in: [...AKTIVE] } } }),
@@ -287,7 +306,7 @@ export async function rotationsStand(altId: string) {
: !testsGruen
? (3 as const)
: (4 as const);
return { alt, gesperrt, neu, schritt, neuOffen, tests, testsGruen };
return { alt, gesperrt, neu, schritt, neuOffen, tests, testsGruen, abgebrochen };
}
/**
@@ -302,6 +321,7 @@ export async function rotationAltWiderrufen(
const stand = await rotationsStand(altId);
if (!stand.neu) throw new RotationsFehler("Erst den neuen Schlüssel erzeugen.");
if (stand.schritt === 5) return;
if (stand.abgebrochen) throw new RotationsFehler("Die Rotation wurde abgebrochen.");
if (stand.schritt < 4) {
throw new RotationsFehler(
stand.schritt === 3
@@ -311,3 +331,46 @@ export async function rotationAltWiderrufen(
}
await widerrufEinleiten([altId], akteur, optionen);
}
/**
* Laufende Rotation abbrechen (#032): der neue Schluessel wird ueber den normalen Widerruf (#024)
* entfernt – mit Dry-Run, denn er kann schon ausgerollt sein. Der alte bleibt aktiv und
* unveraendert; wo beide denselben Alias tragen, zeigt die config nach dem Anwenden wieder auf
* die alte Datei (juengste *verbleibende* Zuweisung gewinnt, src/worker/ablaeufe/privat.ts).
*
* `vorgaengerId` des neuen Schluessels bleibt fuer die Historie stehen; dass abgebrochen wurde,
* erkennt man am Widerruf des Nachfolgers und am Protokolleintrag. Ist der alte Schluessel schon
* im Widerruf (Schritt 5), gibt es kein Zurueck mehr – sonst waeren am Ende beide halb entfernt.
*/
export async function rotationAbbrechen(
altId: string,
akteur: Akteur,
optionen: { readonly privatErlaubt: boolean },
): Promise<void> {
const alt = await altLaden(altId);
const neu = alt.nachfolger;
if (!neu) throw new RotationsFehler("Für diesen Schlüssel läuft keine Rotation.");
if (alt.status === "ROTIERT") throw new RotationsFehler("Die Rotation ist bereits abgeschlossen.");
// Idempotent: ein zweiter Klick (oder ein Neuladen) setzt den laufenden Abbruch nur fort.
if (rotationAbgebrochen(neu)) return;
if (alt.widerrufEingeleitetAm) {
throw new RotationsFehler(
"Der alte Schlüssel wird bereits entfernt – die Rotation lässt sich nicht mehr abbrechen.",
);
}
await widerrufEinleiten([neu.id], akteur, {
privatErlaubt: optionen.privatErlaubt,
zusaetzlich: async (tx) => {
await protokollieren(
{
akteur,
aktion: "schluessel.rotation.abgebrochen",
ergebnis: "ERFOLG",
fingerprint: alt.fingerprint,
details: { name: alt.name, nachfolgerId: neu.id, nachfolger: neu.name },
},
tx,
);
},
});
}
+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[] = [];
+10 -2
View File
@@ -37,7 +37,7 @@ const SCHLUESSEL_KOPF = {
status: true,
fingerprint: true,
widerrufEingeleitetAm: true,
nachfolger: { select: { id: true, status: true } },
nachfolger: { select: { id: true, status: true, widerrufEingeleitetAm: true } },
} satisfies Prisma.SchluesselSelect;
type SchluesselKopf = Prisma.SchluesselGetPayload<{ select: typeof SCHLUESSEL_KOPF }>;
@@ -167,6 +167,11 @@ export async function zielkontoKandidaten(schluesselIds: readonly string[]): Pro
export type EinleitenOptionen = {
/** Darf der Akteur Private Keys entfernen (privateKeyAusrollen)? Prueft die Aktion per darf(). */
readonly privatErlaubt: boolean;
/**
* Laeuft in derselben Transaktion wie das Vormerken – etwa der Protokolleintrag eines
* Rotationsabbruchs (#032), damit es keinen Abbruch ohne Eintrag gibt.
*/
readonly zusaetzlich?: (tx: Prisma.TransactionClient) => Promise<void>;
};
/**
@@ -264,6 +269,7 @@ export async function widerrufEinleiten(
tx,
);
}
await optionen.zusaetzlich?.(tx);
});
}
@@ -406,7 +412,9 @@ export async function widerrufAbschliessen(
continue;
}
// Ein aktiver Nachfolger heisst: der Widerruf ist der letzte Schritt einer Rotation (#023).
const status = s.nachfolger?.status === "AKTIV" ? "ROTIERT" : "WIDERRUFEN";
// Ein Nachfolger im Widerruf ist eine abgebrochene Rotation (#032) – ersetzt ist dann nichts.
const status =
s.nachfolger?.status === "AKTIV" && !s.nachfolger.widerrufEingeleitetAm ? "ROTIERT" : "WIDERRUFEN";
await prisma.$transaction(async (tx) => {
const geaendert = await tx.schluessel.updateMany({
where: { id: s.id, status: { in: ["AKTIV", "ABGELAUFEN"] } },
+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,
+23 -1
View File
@@ -236,8 +236,30 @@ describe("Protokoll", () => {
expect(pruefung?.ergebnis).toBe(zweiter.id);
});
it("vergibt IDs ohne Zweierschritte (#032)", async () => {
// Frueher zogen Spalten-Default und Trigger je eine Nummer aus der Sequenz.
const [a, b, c] = [await eintragen("eins"), await eintragen("zwei"), await eintragen("drei")];
expect(b.id).toBe(a.id + 1n);
expect(c.id).toBe(a.id + 2n);
const [pruefung] = await prisma.$queryRaw<
{ ergebnis: bigint | null }[]
>`SELECT protokoll_kette_pruefen() AS ergebnis`;
expect(pruefung?.ergebnis).toBeNull();
});
it("ueberschreibt eine mitgegebene ID unter der Sperre (#032)", async () => {
const erster = await eintragen("eins");
const zweiter = await prisma.protokoll.create({
data: { id: 999n, akteurName: "Test", aktion: "zwei", ergebnis: "ERFOLG" },
});
expect(zweiter.id).toBe(erster.id + 1n);
expect(zweiter.vorgaengerHash).toBe(erster.hash);
});
it("bleibt bei gleichzeitigem Schreiben eine lueckenlose Kette", async () => {
await Promise.all(Array.from({ length: 20 }, (_, i) => eintragen(`parallel-${i}`)));
const zeilen = await Promise.all(Array.from({ length: 20 }, (_, i) => eintragen(`parallel-${i}`)));
const ids = zeilen.map((z) => z.id).sort((x, y) => (x < y ? -1 : 1));
expect(ids.at(-1)! - ids[0]!).toBe(19n);
const [pruefung] = await prisma.$queryRaw<
{ ergebnis: bigint | null }[]
>`SELECT protokoll_kette_pruefen() AS ergebnis`;
+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);
});
+25
View File
@@ -1,5 +1,6 @@
import { execFileSync } from "node:child_process";
import { expect, type Page } from "@playwright/test";
import pg from "pg";
import "dotenv/config";
/**
@@ -35,6 +36,30 @@ export async function anmelden(page: Page): Promise<void> {
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
}
/** Direkter Zugriff auf die Datenbank des gebauten Stands – fuer Vorbedingungen und Pruefungen. */
export async function datenbank<T>(arbeit: (client: pg.Client) => Promise<T>): Promise<T> {
const client = new pg.Client({ connectionString: process.env["DATABASE_URL"] });
await client.connect();
try {
return await arbeit(client);
} finally {
await client.end();
}
}
/**
* Erneute Anmeldung beim Mock-OIDC (#014). Der Mock uebernimmt nur die Claims aus dem
* Formular; `auth_time` muss deshalb mitgeschickt werden, sonst lehnt sshkv die Bestaetigung ab.
*/
export async function oidcBestaetigen(page: Page): Promise<void> {
await expect(page).toHaveURL(/\/bestaetigen\?ziel=/);
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();
}
export async function hostEinrichten(page: Page, name: string, port: string): Promise<void> {
await page.goto("/hosts/neu");
await page.getByLabel("Name").fill(name);
+46
View File
@@ -69,3 +69,49 @@ test("Rotation mit Schrittanzeige", async ({ page }) => {
await archivieren(page, hostA);
await archivieren(page, hostB);
});
test("Rotation abbrechen, nachdem der neue Schlüssel ausgerollt ist (#032)", async ({ page }) => {
const kennung = Date.now().toString(36);
const hostA = `zz-e2e-a-${kennung}`;
const hostB = `zz-e2e-b-${kennung}`;
const zweck = `zz-e2e Abbruch ${kennung}`;
zieleZuruecksetzen();
await anmelden(page);
await hostEinrichten(page, hostA, PORT.a);
await hostEinrichten(page, hostB, PORT.b);
const schluesselSeite = await kernAufbauen(page, zweck, hostA, hostB);
await page.reload();
await page.getByRole("link", { name: "Rotieren" }).click();
await page.getByRole("button", { name: "Neuen Schlüssel erzeugen und Zuweisungen übernehmen" }).click();
await page.getByRole("button", { name: "Dry-Run starten" }).click({ timeout: 60_000 });
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
await expect(page.getByRole("button", { name: "Verbindungen testen" })).toBeVisible({ timeout: 60_000 });
expect(aufZiel("ziel-a", "cat /root/.ssh/config")).toMatch(/IdentityFile ~\/\.ssh\/.*-r\d{8}$/m);
// Abbrechen: Rueckfrage, dann Widerruf des neuen Schluessels mit Dry-Run.
await page.getByRole("button", { name: "Rotation abbrechen" }).click();
const rueckfrage = page.getByRole("group", { name: "Rotation abbrechen?" });
await expect(rueckfrage.getByText(`„${zweck}“ bleibt aktiv und unverändert`)).toBeVisible();
await rueckfrage.getByRole("button", { name: "Neuen Schlüssel widerrufen" }).click();
await expect(page.getByRole("heading", { name: "Rotation wird abgebrochen" })).toBeVisible();
await page.getByRole("button", { name: "Dry-Run starten" }).click();
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
await expect(page.getByRole("heading", { name: "Rotation abgebrochen" })).toBeVisible({ timeout: 60_000 });
// Auf den Hosts: wie vorher. Der Alias geht wieder mit dem alten Schluessel.
expect(aufZiel("ziel-a", "ls /root/.ssh")).not.toMatch(/-r\d{8}/);
expect(aufZiel("ziel-a", "cat /root/.ssh/config")).not.toMatch(/-r\d{8}/);
expect(aufZiel("ziel-a", "ssh -o BatchMode=yes zu-b id -un").trim()).toBe("root");
// Der alte bleibt aktiv; die Kette zeigt den widerrufenen Nachfolger, kein „Rotieren“ mehr.
await page.goto(schluesselSeite);
const kette = page.getByRole("list", { name: "Rotationskette" });
await expect(kette.getByText("widerrufen", { exact: true })).toBeVisible();
await expect(page.getByText(/Die Rotation zu „.*“ wurde abgebrochen/)).toBeVisible();
await expect(page.getByRole("link", { name: "Rotieren" })).toHaveCount(0);
await archivieren(page, hostA);
await archivieren(page, hostB);
});
+1 -24
View File
@@ -1,7 +1,7 @@
import { expect, test, type Page } from "@playwright/test";
import pg from "pg";
import "dotenv/config";
import { schluesselpaarErzeugen } from "../../src/lib/ssh/openssh";
import { datenbank, oidcBestaetigen } from "./hilfen";
/**
* Schluessel im gebauten Stand mit laufendem Worker (#012–#014). Testdaten heissen zz-e2e-…
@@ -17,16 +17,6 @@ async function oidcAnmelden(page: Page, rolle: "admin" | "operator" | "reader")
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
}
async function datenbank<T>(arbeit: (client: pg.Client) => Promise<T>): Promise<T> {
const client = new pg.Client({ connectionString: process.env["DATABASE_URL"] });
await client.connect();
try {
return await arbeit(client);
} finally {
await client.end();
}
}
function eindeutig(): string {
return `zz-e2e-${Date.now().toString(36)}`;
}
@@ -104,19 +94,6 @@ test("Leser hinterlegt einen eigenen Public Key und entfernt ihn wieder", async
await expect(page.locator("li", { hasText: name })).toHaveCount(0);
});
/**
* Erneute Anmeldung beim Mock-OIDC (#014). Der Mock uebernimmt nur die Claims aus dem
* Formular; `auth_time` muss deshalb mitgeschickt werden, sonst lehnt sshkv die Bestaetigung ab.
*/
async function oidcBestaetigen(page: Page) {
await expect(page).toHaveURL(/\/bestaetigen\?ziel=/);
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();
}
test("Admin zeigt den Private Key nach Bestätigung an, lädt ihn herunter und löscht ihn", async ({
page,
}) => {
+90 -1
View File
@@ -1,10 +1,12 @@
import { expect, test } from "@playwright/test";
import { expect, test, type Page } from "@playwright/test";
import {
anmelden,
archivieren,
aufZiel,
datenbank,
hostEinrichten,
kernAufbauen,
oidcBestaetigen,
PORT,
zieleZuruecksetzen,
} from "./hilfen";
@@ -59,3 +61,90 @@ test("Widerruf mit einem Klick", async ({ page }) => {
await archivieren(page, hostA);
await archivieren(page, hostB);
});
/** Schluessel erzeugen, Public Key auf `host` zuweisen und ausrollen. Liefert den Key-Teil. */
async function publicKeyAusrollen(page: Page, zweck: string, host: string): Promise<string> {
await page.goto("/schluessel/neu");
await page.getByLabel("Zweck").fill(zweck);
await page.getByRole("button", { name: "Schlüssel erzeugen" }).click();
await expect(page.getByRole("heading", { name: zweck })).toBeVisible({ timeout: 30_000 });
const zeile = (await page.getByLabel("Public Key", { exact: true }).textContent()) ?? "";
const keyTeil = zeile.trim().split(/\s+/)[1] ?? "";
expect(keyTeil).toMatch(/^AAAA/);
await page.getByRole("button", { name: "Ausrollen" }).nth(1).click();
const oeffentlich = page.getByRole("dialog", { name: "Public Key zuweisen" });
await oeffentlich.getByLabel("Ziel", { exact: true }).selectOption({ label: host });
await oeffentlich.getByRole("button", { name: "Zuweisen" }).click();
await expect(oeffentlich).toBeHidden();
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.")).toBeVisible({ timeout: 60_000 });
return keyTeil;
}
test("Massenwiderruf mit frischer Bestätigung (#032)", async ({ page }) => {
const kennung = Date.now().toString(36);
const hostB = `zz-e2e-mb-${kennung}`;
const praefix = `zz-e2e Massen ${kennung}`;
const zwecke = [`${praefix} eins`, `${praefix} zwei`];
await anmelden(page);
// Eine Bestaetigung aus der Anmeldung eben oder einem frueheren Lauf soll nicht mitzaehlen.
await datenbank((client) =>
client.query(`UPDATE "Benutzer" SET "bestaetigtAm" = NULL WHERE "email" = 'admin@sshkv.test'`),
);
await hostEinrichten(page, hostB, PORT.b);
const keys = [];
for (const zweck of zwecke) keys.push(await publicKeyAusrollen(page, zweck, hostB));
const vorher = aufZiel("ziel-b", "cat /root/.ssh/authorized_keys");
for (const key of keys) expect(vorher).toContain(key);
// Auswahl in der Liste – ein GET-Formular auf die Massenwiderruf-Seite.
await page.goto(`/schluessel?q=${encodeURIComponent(praefix)}`);
for (const zweck of zwecke)
await page.getByRole("checkbox", { name: `${zweck} für den Massenwiderruf auswählen` }).check();
await page.getByRole("button", { name: "Ausgewählte widerrufen" }).click();
await expect(page).toHaveURL(/\/schluessel\/widerruf\?id=/);
await expect(page.getByRole("heading", { name: "2 Schlüssel widerrufen?" })).toBeVisible();
for (const zweck of zwecke)
await expect(
page.getByRole("region", { name: `Widerruf von ${zweck}` }).getByText(`root@${hostB}`),
).toBeVisible();
// Ohne frische Bestaetigung merkt sshkv nichts vor und schickt zur erneuten Anmeldung.
await page.getByRole("button", { name: "2 Schlüssel widerrufen" }).click();
await page.getByRole("link", { name: /Anmeldung bestätigen/ }).click();
await oidcBestaetigen(page);
await expect(page).toHaveURL(/\/schluessel\/widerruf\?id=/);
const eingeleitet = await datenbank((client) =>
client.query(
`SELECT count(*)::int AS n FROM "Schluessel" WHERE "name" = ANY($1) AND "widerrufEingeleitetAm" IS NOT NULL`,
[zwecke],
),
);
expect(eingeleitet.rows[0]).toEqual({ n: 0 });
await page.getByRole("button", { name: "2 Schlüssel widerrufen" }).click();
await expect(page.getByRole("heading", { name: "Widerruf läuft" })).toBeVisible();
// Ein gemeinsamer Dry-Run fuer beide Schluessel, dann anwenden; der Abschluss laeuft danach.
await page.getByRole("button", { name: "Dry-Run starten" }).click();
await expect(page.getByText(`root@${hostB}:/root/.ssh/authorized_keys`)).toBeVisible({ timeout: 30_000 });
await page.getByRole("button", { name: /Änderung anwenden/ }).click();
await expect(page.getByRole("heading", { name: "Widerruf läuft" })).toBeHidden({ timeout: 60_000 });
const gewaehlt = page.locator("section", {
has: page.getByRole("heading", { name: "Gewählte Schlüssel" }),
});
await expect(gewaehlt.getByText("widerrufen", { exact: true })).toHaveCount(2);
await expect(gewaehlt.getByText("Widerruf unvollständig")).toHaveCount(0);
const nachher = aufZiel("ziel-b", "cat /root/.ssh/authorized_keys");
for (const key of keys) expect(nachher).not.toContain(key);
const status = await datenbank((client) =>
client.query(`SELECT "status" FROM "Schluessel" WHERE "name" = ANY($1)`, [zwecke]),
);
expect(status.rows).toEqual([{ status: "WIDERRUFEN" }, { status: "WIDERRUFEN" }]);
await archivieren(page, hostB);
});
+8
View File
@@ -128,6 +128,14 @@ test("Matrix zeigt Gruppen-Zuweisung, filtert, weist zu und exportiert", async (
`"${name} Backup";"SHA256:${name}";"${name}-h2";"root";"Public Key";"${name}-g";"restrict";"noch nicht ausgerollt"`,
);
expect(text).toContain(`"${name}-h3";"deploy";"Public Key";""`);
// „Zum Dry-Run“ springt direkt zur Dry-Run-Karte der Schluesselseite (#032).
await matrix.getByRole("button", { name: new RegExp(`auf ${name}-h3: Public Key direkt`) }).click();
await dialog.getByRole("link", { name: "Zum Dry-Run" }).click();
await expect(page).toHaveURL(new RegExp(`/schluessel/${ids.schluessel}#dry-run$`));
const karte = page.locator("#dry-run");
await expect(karte).toContainText("Dry-Run-Vorschau");
await expect(karte).toBeInViewport();
});
test("Leser sieht die Matrix, aber keinen Zuweisen-Knopf", async ({ page }) => {
+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;
+81 -28
View File
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import { rotationsDatum } from "@/lib/rotation";
import {
rotationAbbrechen,
rotationAltWiderrufen,
rotationStarten,
rotationUebernehmen,
@@ -55,38 +56,44 @@ async function trockenlaufUndAusrollen(schluesselId: string) {
expect(ausrollen.fehler).toBeNull();
}
/** Dienst-Key wie nach dem Assistenten: Private Key auf A (Alias zu-b), Public Key auf B, ausgerollt. */
async function alterSchluesselAusgerollt(aId: string, bId: string) {
const paar = await schluesselpaarVersiegeltErzeugen("ED25519", "sshkv:backup");
const alt = await prisma.schluessel.create({
data: {
id: paar.id,
name: "Backup",
slug: "backup",
typ: "ED25519",
kategorie: "DIENST",
publicKey: paar.publicKeyZeile,
fingerprint: paar.fingerprint,
kommentar: "sshkv:backup",
...paar.versiegelt,
},
select: { id: true, publicKey: true },
});
await privateVerwendungAnlegen(
{
schluesselId: alt.id,
hostId: aId,
zielbenutzer: "root",
pfad: "~/.ssh/id_backup",
sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "root", port: 22 },
knownHostsFuer: [bId],
},
AKTEUR,
);
await publicVerwendungAnlegen({ schluesselId: alt.id, hostId: bId, zielbenutzer: "root" }, AKTEUR);
await trockenlaufUndAusrollen(alt.id);
return alt;
}
describe("Rotation", () => {
it("tauscht den Schluessel aus, ohne dass der Alias aufhoert zu funktionieren", async () => {
const a = await eingerichteterHost(zielA, "ziel-a");
const b = await eingerichteterHost(zielB, "ziel-b");
const paar = await schluesselpaarVersiegeltErzeugen("ED25519", "sshkv:backup");
const alt = await prisma.schluessel.create({
data: {
id: paar.id,
name: "Backup",
slug: "backup",
typ: "ED25519",
kategorie: "DIENST",
publicKey: paar.publicKeyZeile,
fingerprint: paar.fingerprint,
kommentar: "sshkv:backup",
...paar.versiegelt,
},
select: { id: true, publicKey: true },
});
await privateVerwendungAnlegen(
{
schluesselId: alt.id,
hostId: a.id,
zielbenutzer: "root",
pfad: "~/.ssh/id_backup",
sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "root", port: 22 },
knownHostsFuer: [b.id],
},
AKTEUR,
);
await publicVerwendungAnlegen({ schluesselId: alt.id, hostId: b.id, zielbenutzer: "root" }, AKTEUR);
await trockenlaufUndAusrollen(alt.id);
const alt = await alterSchluesselAusgerollt(a.id, b.id);
// 1–2: Nachfolger erzeugen, Zuweisungen uebernehmen.
await vorgangImWorker(await rotationStarten(alt.id, AKTEUR));
@@ -123,4 +130,50 @@ describe("Rotation", () => {
const ssh = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un");
expect(ssh.stdout.trim()).toBe("root");
});
it("bricht ab: der ausgerollte neue Schluessel verschwindet, der alte arbeitet weiter (#032)", async () => {
const a = await eingerichteterHost(zielA, "ziel-a");
const b = await eingerichteterHost(zielB, "ziel-b");
const alt = await alterSchluesselAusgerollt(a.id, b.id);
const altVorher = await prisma.schluessel.findUniqueOrThrow({ where: { id: alt.id } });
await vorgangImWorker(await rotationStarten(alt.id, AKTEUR));
await rotationUebernehmen(alt.id, AKTEUR, { privatErlaubt: true });
const neu = await prisma.schluessel.findFirstOrThrow({
where: { vorgaengerId: alt.id },
select: { id: true, publicKey: true },
});
await trockenlaufUndAusrollen(neu.id);
const neuerPfad = `id_backup-r${rotationsDatum().kompakt}`;
expect((await befehlAlsRoot(zielA, "cat /root/.ssh/config")).stdout).toContain(
`IdentityFile ~/.ssh/${neuerPfad}`,
);
// Abbrechen = Widerruf des neuen Schluessels mit Dry-Run und Anwenden.
await rotationAbbrechen(alt.id, AKTEUR, { privatErlaubt: true });
await trockenlaufUndAusrollen(neu.id);
expect((await widerrufAbschliessen([neu.id], AKTEUR))[0]?.status).toBe("WIDERRUFEN");
const authorized = (await befehlAlsRoot(zielB, "cat /root/.ssh/authorized_keys")).stdout;
expect(authorized).not.toContain(neu.publicKey.split(" ")[1]!);
expect(authorized).toContain(alt.publicKey.split(" ")[1]!);
const dateien = (await befehlAlsRoot(zielA, "ls /root/.ssh")).stdout.split("\n");
expect(dateien).toContain("id_backup");
expect(dateien).not.toContain(neuerPfad);
// Der Alias zeigt wieder auf die alte Datei – und funktioniert.
const config = (await befehlAlsRoot(zielA, "cat /root/.ssh/config")).stdout;
expect(config.match(/^Host zu-b$/gm)).toHaveLength(1);
expect(config).toContain("IdentityFile ~/.ssh/id_backup\n");
const ssh = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un");
expect(ssh.stdout.trim()).toBe("root");
// Der alte Schluessel ist unveraendert, seine Zuweisungen sind ausgerollt.
expect(await prisma.schluessel.findUniqueOrThrow({ where: { id: alt.id } })).toEqual(altVorher);
const zustaende = [
...(await prisma.publicVerwendung.findMany({ where: { schluesselId: alt.id } })),
...(await prisma.privateVerwendung.findMany({ where: { schluesselId: alt.id } })),
].map((v) => v.zustand);
expect(zustaende).toEqual(["AUSGEROLLT", "AUSGEROLLT"]);
expect((await rotationsStand(alt.id)).abgebrochen).toBe(true);
});
});
+1 -1
View File
@@ -75,7 +75,7 @@ beforeEach(async () => {
describe("protokollSeite", () => {
it("blaettert ueber die ID, neueste zuerst, ohne Luecken und Doppelte", async () => {
// IDs sind nicht lueckenlos (der Trigger zieht die Sequenz selbst) – nur die Ordnung zaehlt.
// IDs koennen Luecken haben (Rollbacks, alte Zeilen vor #032) – nur die Ordnung zaehlt.
const ids: string[] = [];
for (let i = 0; i < SEITENGROESSE + 7; i += 1)
ids.unshift(String((await eintragen({ aktion: "host.angelegt" })).id));
+117 -1
View File
@@ -8,13 +8,14 @@ import { rotationsDatum, rotationsName, rotationsPfad } from "@/lib/rotation";
import { rotationsKette } from "@/server/queries/schluessel";
import {
RotationsFehler,
rotationAbbrechen,
rotationAltWiderrufen,
rotationGesperrt,
rotationStarten,
rotationUebernehmen,
rotationsStand,
} from "@/server/services/rotation";
import { widerrufAbschliessen } from "@/server/services/widerruf";
import { widerrufAbschliessen, widerrufEinleiten } from "@/server/services/widerruf";
import { masterKeyVergessen } from "@/worker/geheimnisse";
import { datenbankLeeren } from "./hilfen/db";
import { datenbankAlsText, vorgangImWorker, warteschlangeStoppen } from "./hilfen/vorgang";
@@ -258,3 +259,118 @@ describe("Rotation Schritt fuer Schritt", () => {
await expect(rotationStarten(person.id, admin)).rejects.toThrow(/neuen Public Key/);
});
});
describe("Rotation abbrechen (#032)", () => {
/** Rotation bis Schritt 3: Nachfolger uebernommen und ausgerollt. */
async function laufendeRotation() {
const altId = await alterSchluessel();
const neuId = await nachfolgerErzeugen(altId);
await rotationUebernehmen(altId, admin, { privatErlaubt: true });
const jetzt = new Date();
await prisma.publicVerwendung.updateMany({
where: { schluesselId: neuId },
data: { zustand: "AUSGEROLLT", zuletztAngewendetAm: jetzt },
});
await prisma.privateVerwendung.updateMany({
where: { schluesselId: neuId },
data: { zustand: "AUSGEROLLT", zuletztAngewendetAm: jetzt },
});
return { altId, neuId };
}
async function verwendungen(schluesselId: string) {
const [oeffentlich, privat] = await Promise.all([
prisma.publicVerwendung.findMany({ where: { schluesselId }, select: { zustand: true } }),
prisma.privateVerwendung.findMany({ where: { schluesselId }, select: { zustand: true } }),
]);
return [...oeffentlich, ...privat].map((v) => v.zustand).sort();
}
it("widerruft den neuen Schluessel, der alte bleibt unveraendert", async () => {
const { altId, neuId } = await laufendeRotation();
const altVorher = await prisma.schluessel.findUniqueOrThrow({ where: { id: altId } });
await expect(rotationAbbrechen(altId, admin, { privatErlaubt: false })).rejects.toThrow(/Admin/);
await rotationAbbrechen(altId, admin, { privatErlaubt: true });
// Ausgerollt heisst: erst Dry-Run und Anwenden entfernen ihn – vorgemerkt, nicht sofort weg.
expect(await verwendungen(neuId)).toEqual(["ENTFERNEN", "ENTFERNEN"]);
expect(await verwendungen(altId)).toEqual(["AUSGEROLLT", "AUSGEROLLT"]);
expect(await prisma.schluessel.findUniqueOrThrow({ where: { id: altId } })).toEqual(altVorher);
const stand = await rotationsStand(altId);
expect(stand.abgebrochen).toBe(true);
// Kein Weiterdrehen mehr, ein zweiter Abbruch aendert nichts.
await expect(rotationUebernehmen(altId, admin, { privatErlaubt: true })).rejects.toThrow(/abgebrochen/);
await expect(rotationAltWiderrufen(altId, admin, { privatErlaubt: true })).rejects.toThrow(/abgebrochen/);
await rotationAbbrechen(altId, admin, { privatErlaubt: true });
expect(await prisma.protokoll.count({ where: { aktion: "schluessel.rotation.abgebrochen" } })).toBe(1);
const eintrag = await prisma.protokoll.findFirstOrThrow({
where: { aktion: "schluessel.rotation.abgebrochen" },
select: { fingerprint: true, details: true },
});
expect(eintrag).toMatchObject({ fingerprint: "SHA256:alt", details: { nachfolgerId: neuId } });
// Nach dem Anwenden: der Nachfolger ist WIDERRUFEN (nicht ROTIERT), die Kette bleibt.
await prisma.publicVerwendung.updateMany({
where: { schluesselId: neuId },
data: { zustand: "ENTFERNT" },
});
await prisma.privateVerwendung.updateMany({
where: { schluesselId: neuId },
data: { zustand: "ENTFERNT" },
});
expect((await widerrufAbschliessen([neuId], admin))[0]?.status).toBe("WIDERRUFEN");
expect((await rotationsKette(altId)).map((g) => [g.id, g.status])).toEqual([
[altId, "AKTIV"],
[neuId, "WIDERRUFEN"],
]);
expect(
(await prisma.schluessel.findUniqueOrThrow({ where: { id: neuId }, select: { vorgaengerId: true } }))
.vorgaengerId,
).toBe(altId);
await expect(rotationStarten(altId, admin)).rejects.toThrow(/abgebrochen/);
});
it("setzt nie ausgerollte Zuweisungen des neuen Schluessels sofort auf ENTFERNT", async () => {
const altId = await alterSchluessel();
const neuId = await nachfolgerErzeugen(altId);
await rotationUebernehmen(altId, admin, { privatErlaubt: true });
await rotationAbbrechen(altId, admin, { privatErlaubt: true });
expect(await verwendungen(neuId)).toEqual(["ENTFERNT", "ENTFERNT"]);
expect((await widerrufAbschliessen([neuId], admin))[0]?.status).toBe("WIDERRUFEN");
expect(await verwendungen(altId)).toEqual(["AUSGEROLLT", "AUSGEROLLT"]);
});
it("geht nicht mehr, sobald der alte Schluessel entfernt wird", async () => {
const { altId } = await laufendeRotation();
const privat = await prisma.privateVerwendung.findFirstOrThrow({
where: { schluessel: { vorgaengerId: altId } },
select: { id: true },
});
await prisma.vorgang.create({
data: { art: "VERBINDUNGSTEST", status: "ERLEDIGT", eingabe: { privatVerwendungId: privat.id } },
});
await rotationAltWiderrufen(altId, admin, { privatErlaubt: true });
await expect(rotationAbbrechen(altId, admin, { privatErlaubt: true })).rejects.toThrow(
/nicht mehr abbrechen/,
);
await expect(rotationAbbrechen(b.id, admin, { privatErlaubt: true })).rejects.toThrow(/gibt es nicht/);
});
it("macht aus dem Widerruf des alten Schluessels kein ROTIERT, solange der Abbruch laeuft", async () => {
const { altId, neuId } = await laufendeRotation();
await rotationAbbrechen(altId, admin, { privatErlaubt: true });
await widerrufEinleiten([altId], admin, { privatErlaubt: true });
await prisma.publicVerwendung.updateMany({
where: { schluesselId: altId },
data: { zustand: "ENTFERNT" },
});
await prisma.privateVerwendung.updateMany({
where: { schluesselId: altId },
data: { zustand: "ENTFERNT" },
});
expect((await widerrufAbschliessen([altId], admin))[0]?.status).toBe("WIDERRUFEN");
expect((await prisma.schluessel.findUniqueOrThrow({ where: { id: neuId } })).status).toBe("AKTIV");
});
});
+10
View File
@@ -9,6 +9,7 @@ import {
ablaufZustand,
fingerprintKurz,
freierSlug,
idsAusSuchparameter,
slugAus,
typAnzeige,
erzeugenSchema,
@@ -66,6 +67,15 @@ function kern(privateKey: string): string {
}
describe("Regeln (rein)", () => {
it("liest IDs aus dem Suchparameter, auch UUIDs vom Worker (#032)", () => {
const uuid = "5f703ff1-3877-4af4-af08-84338237fcd8";
expect(idsAusSuchparameter([uuid, "cm1abc"])).toEqual([uuid, "cm1abc"]);
expect(idsAusSuchparameter(`a,b,a`)).toEqual(["a", "b"]);
expect(idsAusSuchparameter(["x y", "../etc", ""])).toEqual([]);
expect(idsAusSuchparameter(undefined)).toEqual([]);
expect(idsAusSuchparameter(["a", "b", "c"], 2)).toEqual(["a", "b"]);
});
it.each([
["Backup srv-backup01 → srv-pve01", "backup-srv-backup01-srv-pve01"],
["Größter Schlüssel für Ärzte", "groesster-schluessel-fuer-aerzte"],