Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dad8e5631 | ||
|
|
2c658b1223 | ||
|
|
00cff56500 | ||
|
|
685b76657d | ||
|
|
b80f14c5d7 | ||
|
|
458f62289a | ||
|
|
5c42f8dd8d |
@@ -15,6 +15,21 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
### Sicherheit
|
||||
|
||||
## [0.3.0] – 2026-10-12
|
||||
|
||||
Phase 3: Host-zu-Host – Private Keys, Zielkonten, Vorlagen und Assistent.
|
||||
|
||||
### Hinzugefügt
|
||||
- Host-zu-Host-Assistent: Vorlage wählen, Host A und B, Zielbenutzer, Einschränkungen und `~/.ssh/config`-Alias, Fingerprint von B bestätigen, Dry-Run, ausrollen, Verbindung testen – in einem Durchgang (#020)
|
||||
- Zielbenutzer (z. B. `deploy`) auf dem Ziel anlegen, mit sudoers-Datei für bestimmte Befehle oder vollen Zugriff; Vorschau vor dem Anlegen, Prüfung mit `visudo -c` (#018)
|
||||
- Vorlagen verwalten: eingebaute „Deployment-Zugang“, „Backup-Zugang“, „Admin-Zugang Person“, eigene anlegen und archivieren, Live-Vorschau der Platzhalter und des sudoers-Inhalts (#019)
|
||||
- Übersicht verlinkt den Assistenten
|
||||
|
||||
### Sicherheit
|
||||
- Neue Benutzer mit sudo nur durch Admins mit frischer Bestätigung
|
||||
- sshkv überschreibt keine fremde sudoers-Datei und fasst Systemkonten nicht an; ein Widerruf löscht nur Benutzer, die sshkv selbst angelegt hat
|
||||
- Platzhalter-Werte können keine zusätzlichen authorized_keys-Optionen einschleusen
|
||||
|
||||
## [0.2.0] – 2026-10-12
|
||||
|
||||
Phase 2: Schlüssel, Personen und Ausrollen von Public Keys – dazu vorgezogen aus Phase 3 das Ausrollen von Private Keys.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
## Zeitübersicht
|
||||
|
||||
**Gesamt (Wandzeit):** 2 h 25 min
|
||||
**Summe der Issue-Zeiten:** 7 h 08 min – Sub-Agenten arbeiteten parallel an #002, #006, #008, #011 und #012–#014.
|
||||
**Gesamt (Wandzeit):** 2 h 55 min
|
||||
**Summe der Issue-Zeiten:** 8 h 23 min – Sub-Agenten arbeiteten parallel an #002, #006, #008, #011, #012–#014 und #019 (Oberfläche).
|
||||
|
||||
| Issue | Zeit |
|
||||
|---|---|
|
||||
@@ -25,14 +25,15 @@
|
||||
| #015 authorized_keys-Parser | 0 h 15 min |
|
||||
| #016 Public Key ausrollen | 0 h 40 min |
|
||||
| #017 Private Key ausrollen | 0 h 45 min |
|
||||
| #018 Zielbenutzer und sudoers (Teil) | 0 h 10 min |
|
||||
| #019 Vorlagen (Teil) | 0 h 10 min |
|
||||
| #020 Verbindungstest (Teil) | 0 h 05 min |
|
||||
| #018 Zielbenutzer und sudoers | 0 h 30 min |
|
||||
| #019 Vorlagen | 0 h 20 min |
|
||||
| #020 Host-zu-Host-Assistent | 0 h 50 min |
|
||||
|
||||
| Meilenstein | Zeit |
|
||||
|---|---|
|
||||
| v0.1.0 | 1 h 25 min (Wandzeit) | **abgeschlossen**
|
||||
| v0.2.0 | 1 h 00 min (Wandzeit) | **abgeschlossen**
|
||||
| v0.3.0 | 0 h 30 min (Wandzeit) | **abgeschlossen**
|
||||
|
||||
---
|
||||
|
||||
@@ -84,3 +85,19 @@
|
||||
- **Private-Key-Ziele im Dry-Run nur für Admins:** Sonst hätte ein Operator einen vorbereiteten Private Key ausrollen lassen können (Freigabe Frage 3).
|
||||
- **Keine Sicherungskopie alter Private Keys** auf dem Host; config-Block am Dateianfang, weil ssh den ersten Treffer nimmt.
|
||||
- **Ein Hook `useVorgang` für alle:** zwei parallel entstandene Fassungen zusammengeführt, beide Schnittstellen bleiben.
|
||||
|
||||
## 2026-10-12 · Sitzung 3 · 11:06–11:36
|
||||
**Issue:** #018–#020
|
||||
**Version:** 0.3.0
|
||||
|
||||
### Erledigt
|
||||
- #019 Vorlagen-Oberfläche (Liste, Formular, Live-Vorschau) von einem Sub-Agenten, gemergt
|
||||
- #018 Zielkonten als Vorgänge mit Migration, Herkunft gemerkt; Integrationstest anlegen → entfernen
|
||||
- #020 Host-zu-Host-Assistent mit Vorlagen, wiederaufnehmbarer Ausführung und Verbindungstest; E2E mit „Deployment-Zugang“
|
||||
- Stand: 423 Unit-/DB-Tests, 31 Integrationstests, 16 E2E-Abläufe grün
|
||||
|
||||
### Entscheidungen
|
||||
- **Migration auf Ortszeit-Reihenfolge umbenannt** (`20261012110000`): Prisma vergibt UTC-Zeitstempel, die Migrationen des Agenten trugen Ortszeit – sonst stünde die neue vor älteren.
|
||||
- **Bestätigung im neuen Tab:** Der Assistent hält seine Eingaben im Speicher; eine Umleitung nach `/bestaetigen` hätte sie gelöscht.
|
||||
- **Ausführung des Assistenten als Kette im Klick** statt als Hook-Zustandsmaschine – einfacher, und erledigte Schritte lassen sich beim Fortsetzen überspringen.
|
||||
- **Prozesse stoppen nur über ein Skript, das Programm und Arbeitsverzeichnis prüft:** Zweimal hatte ein zu breites Suchmuster die eigene Shell getroffen.
|
||||
|
||||
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 214 KiB After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 86 KiB |
@@ -1,20 +1,25 @@
|
||||
# #018 Zielbenutzer anlegen und sudoers
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.3.0
|
||||
**Labels:** backend, sicherheit
|
||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
||||
**Geschätzt:** 3 h **Tatsächlich:** 0 h 30 min
|
||||
|
||||
## Ziel
|
||||
Benutzer (z. B. `deploy`) anlegen, optional sudoers mit Vorschau und `visudo -c`.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] Auswahl keine/bestimmte Befehle/voll
|
||||
- [ ] Entfernen beim Widerruf
|
||||
- [x] Auswahl keine/bestimmte Befehle/voll
|
||||
- [x] Entfernen beim Widerruf
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] Integrationstest
|
||||
- [x] Integrationstest
|
||||
|
||||
## Abhängigkeiten
|
||||
#016
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
- **sudoers** entsteht als `/etc/sudoers.d/.sshkv.XXXX` (Punkt im Namen: sudo ignoriert die Datei), wird mit `visudo -cf` geprüft und erst dann nach `/etc/sudoers.d/sshkv-<benutzer>` verschoben. Kopfzeile kennzeichnet sshkv-Dateien; eine fremde Datei gleichen Namens wird nie überschrieben.
|
||||
- **Befehle:** absoluter Pfad, keine Zeichen mit Sonderbedeutung in sudoers (`, : = \`, Wildcards) – die Liste soll wirken, wie sie aussieht.
|
||||
- **Geschützte Konten** (root, sshkv, Systemkonten) verwaltet sshkv nie als Zielkonto.
|
||||
- **Herkunft gemerkt:** `vonSshkvAngelegt` – nur dann löscht ein Entfernen den Benutzer samt Home, sonst nur die sudoers-Datei.
|
||||
- Vorgänge `ZIELKONTO_ANLEGEN`/`ZIELKONTO_ENTFERNEN` (Migration `20261012110000`), Recht `zielkontenVerwalten` = Admin mit frischer Bestätigung. Oberfläche im Assistenten (#020) mit sudoers-Vorschau.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# #019 Vorlagen
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.3.0
|
||||
**Labels:** backend, ui
|
||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
||||
@@ -8,12 +8,25 @@
|
||||
Eingebaute Vorlagen „Deployment-Zugang“, „Backup-Zugang“, „Admin-Zugang Person“; eigene in den Einstellungen.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] Platzhalter `{{quellHostIp}}` usw.
|
||||
- [x] Platzhalter `{{quellHostIp}}` usw.
|
||||
- [x] Oberfläche: Liste `/vorlagen` (alle lesen), Anlegen/Bearbeiten/Archivieren nur Admin
|
||||
- [x] Live-Vorschau der aufgelösten Optionen, des Alias und der sudoers-Datei
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] Vorlage füllt Assistent vor
|
||||
- [ ] Vorlage füllt Assistent vor (folgt mit dem Assistenten, #020)
|
||||
|
||||
## Abhängigkeiten
|
||||
#017, #018
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
- Vorlagen pflegt nur, wer `einstellungenVerwalten` darf (Admin); lesen darf jede Rolle, weil
|
||||
Vorlagen keine Geheimnisse enthalten und Operatoren sie im Assistenten sehen.
|
||||
- Die Vorschau im Formular ruft dieselben reinen Funktionen wie der Server
|
||||
(`platzhalterErsetzen`, `optionenPruefen`, `sudoersInhalt`) mit eigenen Beispielwerten auf;
|
||||
Fehler werden dort nur angezeigt, verbindlich prüft der Server beim Speichern.
|
||||
- Das Befehlsfeld wird bei anderem sudo-Modus nur ausgeblendet, nicht entfernt – so gehen
|
||||
eingegebene Befehle beim Umschalten nicht verloren; der Service verwirft sie ohnehin.
|
||||
- Die sudoers-Vorschau bricht nicht um: sudoers ist zeilenbasiert, ein optischer Umbruch sähe
|
||||
wie eine zweite Regel aus.
|
||||
- Eingebaute Vorlagen lassen sich bearbeiten, aber nicht archivieren; ihre Bearbeiten-Seite
|
||||
zeigt deshalb keinen Archivieren-Knopf.
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
# #020 Host-zu-Host-Assistent und Verbindungstest
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.3.0
|
||||
**Labels:** ui, backend
|
||||
**Geschätzt:** 4 h **Tatsächlich:** –
|
||||
**Geschätzt:** 4 h **Tatsächlich:** 0 h 50 min
|
||||
|
||||
## Ziel
|
||||
Geführter Ablauf: Schlüssel erzeugen, Private auf A, Public auf B, Host-Key von B in known_hosts von A, Verbindungstest.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] Schrittanzeige
|
||||
- [ ] Zusammenfassung + Dry-Run
|
||||
- [ ] `ssh -o BatchMode=yes … true` auf A
|
||||
- [x] Schrittanzeige
|
||||
- [x] Zusammenfassung + Dry-Run
|
||||
- [x] `ssh -o BatchMode=yes … true` auf A
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] Kern-Anwendungsfall Ende-zu-Ende gegen zwei Test-Container
|
||||
- [x] Kern-Anwendungsfall Ende-zu-Ende gegen zwei Test-Container
|
||||
|
||||
## Abhängigkeiten
|
||||
#019
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
- **Fünf Schritte:** Vorlage & Zweck, Quelle, Ziel (Zielbenutzer, optional anlegen mit sudo, Einschränkungen, Alias, HostName/Port aus Sicht von A, Fingerprint von B bestätigen), Zusammenfassung, Ausrollen (Dry-Run, anwenden, Verbindungstest).
|
||||
- **Vorlage** füllt Zielbenutzer, Anlegen/sudo, Ablaufdatum, `from=`-Muster und Alias vor; Platzhalter werden mit A und B aufgelöst. Ist die Adresse von A keine IP, fragt der Assistent nach.
|
||||
- **Wiederaufnehmbar:** Erledigte Schritte (Schlüssel, Zielkonto, Zuweisungen) werden beim „Fortsetzen“ übersprungen. Braucht das Zielkonto eine frische Bestätigung, geschieht sie in einem neuen Tab – die Eingaben bleiben stehen.
|
||||
- **root als Ziel:** deutlicher Hinweis, dass ein Agent wie Claude Code dann uneingeschränkten Zugriff hätte.
|
||||
- **Verbindungstest:** `ssh -o BatchMode=yes -o StrictHostKeyChecking=yes <alias> true` auf A als Zielbenutzer.
|
||||
- E2E: Vorlage „Deployment-Zugang“ → `deploy` auf B angelegt → A meldet sich als deploy bei B an.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sshkv",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "SSHKeyVault – zentrale Verwaltung von SSH-Schluesseln mit Weboberflaeche",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "VorgangsArt" ADD VALUE 'ZIELKONTO_ANLEGEN';
|
||||
ALTER TYPE "VorgangsArt" ADD VALUE 'ZIELKONTO_ENTFERNEN';
|
||||
@@ -110,6 +110,10 @@ enum VorgangsArt {
|
||||
SCHLUESSEL_IMPORTIEREN
|
||||
/// Gespeicherten Private Key endgueltig verwerfen (#014).
|
||||
PRIVATE_KEY_LOESCHEN
|
||||
/// Zielbenutzer anlegen und sudoers setzen (#018).
|
||||
ZIELKONTO_ANLEGEN
|
||||
/// sudoers und – falls von sshkv angelegt – den Benutzer entfernen (#018, #024).
|
||||
ZIELKONTO_ENTFERNEN
|
||||
}
|
||||
|
||||
enum VorgangsStatus {
|
||||
|
||||
@@ -39,6 +39,13 @@ const SEITEN: { name: string; pfad: string; oeffentlich?: boolean; folgen?: stri
|
||||
{ name: "meine-schluessel", pfad: "/meine-schluessel" },
|
||||
{ name: "hosts", pfad: "/hosts" },
|
||||
{ name: "host-detail", pfad: "/hosts", folgen: 'main table a[href^="/hosts/"]' },
|
||||
{ name: "vorlagen", pfad: "/vorlagen" },
|
||||
// Die eingebaute „Deployment-Zugang“ zeigt Platzhalter, Laufzeit und Benutzer-Anlegen (#019).
|
||||
{
|
||||
name: "vorlage-bearbeiten",
|
||||
pfad: "/vorlagen",
|
||||
folgen: 'main a[aria-label="Deployment-Zugang bearbeiten"]',
|
||||
},
|
||||
];
|
||||
|
||||
const ANSICHTEN = [
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export const metadata: Metadata = { title: "Bestätigt" };
|
||||
|
||||
/** Ziel der Bestaetigung aus dem Assistenten (#020): der Assistent wartet im anderen Tab. */
|
||||
export default async function BestaetigtSeite() {
|
||||
await sitzungErforderlich();
|
||||
return (
|
||||
<Leerzustand
|
||||
titel="Anmeldung bestätigt"
|
||||
text="Die Bestätigung gilt fünf Minuten. Diesen Tab schließen und im Assistenten „Fortsetzen“ wählen."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Assistent } from "@/components/assistent/Assistent";
|
||||
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||
import { assistentDaten } from "@/server/queries/assistent";
|
||||
import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export const metadata: Metadata = { title: "Host-zu-Host-Assistent" };
|
||||
|
||||
/**
|
||||
* Host-zu-Host-Assistent (#020): ein Schluesselpaar fuer genau einen Zweck, Private Key auf
|
||||
* Host A, Public Key auf Host B, Verbindungstest. Nur Admins – es wird ein Private Key ausgerollt.
|
||||
*/
|
||||
export default async function AssistentSeite() {
|
||||
const { benutzer } = await sitzungErforderlich("privateKeyAusrollen", "/assistent");
|
||||
const daten = await assistentDaten();
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
<Seitenkopf
|
||||
oben={<Breadcrumb pfad={[{ titel: "Übersicht", href: "/" }, { titel: "Host-zu-Host-Assistent" }]} />}
|
||||
titel="Host-zu-Host-Assistent"
|
||||
untertitel="Host A soll sich per SSH an Host B anmelden – Schlüssel, Ausrollen und Test in einem Durchgang."
|
||||
/>
|
||||
<Assistent daten={daten} bestaetigt={bestaetigungFrisch(benutzer.bestaetigtAm)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,7 +53,7 @@ export default async function Uebersicht() {
|
||||
aktionen={
|
||||
<>
|
||||
<Button icon={<RefreshCw className="size-4" />}>Jetzt abgleichen</Button>
|
||||
<Button href="/zuweisungen" icon={<Route className="size-4" />}>
|
||||
<Button href="/assistent" icon={<Route className="size-4" />}>
|
||||
Host-zu-Host-Assistent
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||
import { VorlageArchivierenDialog } from "@/components/vorlagen/VorlageArchivierenDialog";
|
||||
import { VorlagenFormular } from "@/components/vorlagen/VorlagenFormular";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { vorlageDetail } from "@/server/queries/vorlagen";
|
||||
|
||||
export const metadata: Metadata = { title: "Vorlage bearbeiten" };
|
||||
|
||||
export default async function VorlageBearbeiten({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
await sitzungErforderlich("einstellungenVerwalten", `/vorlagen/${id}`);
|
||||
const v = await vorlageDetail(id);
|
||||
// Archivierte Vorlagen sind fuer die Oberflaeche weg; das Protokoll kennt sie noch (#019).
|
||||
if (!v || v.archiviertAm) notFound();
|
||||
|
||||
return (
|
||||
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
|
||||
<Seitenkopf
|
||||
oben={<Breadcrumb pfad={[{ titel: "Vorlagen", href: "/vorlagen" }, { titel: v.name }]} />}
|
||||
titel={`${v.name} bearbeiten`}
|
||||
badges={v.eingebaut ? <Badge>eingebaut</Badge> : undefined}
|
||||
untertitel={
|
||||
v.eingebaut
|
||||
? "Eingebaute Vorlagen lassen sich anpassen, aber nicht archivieren. Ein späterer Seed überschreibt Ihre Änderungen nicht."
|
||||
: undefined
|
||||
}
|
||||
aktionen={!v.eingebaut && <VorlageArchivierenDialog vorlageId={v.id} name={v.name} />}
|
||||
/>
|
||||
<VorlagenFormular
|
||||
vorgabe={{
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
beschreibung: v.beschreibung ?? "",
|
||||
// Das Formular kennt nur, was Vorlagen erzeugen; ANDERER/DEPLOYMENT kommen hier nicht vor.
|
||||
schluesselTyp: v.schluesselTyp === "RSA4096" ? "RSA4096" : "ED25519",
|
||||
kategorie: v.kategorie === "PERSON" ? "PERSON" : "DIENST",
|
||||
standardLaufzeitTage: v.standardLaufzeitTage,
|
||||
zielbenutzer: v.zielbenutzer,
|
||||
zielbenutzerAnlegen: v.zielbenutzerAnlegen,
|
||||
sudoModus: v.sudoModus,
|
||||
sudoBefehle: v.sudoBefehle,
|
||||
authorizedKeysOptionen: v.authorizedKeysOptionen ?? "",
|
||||
sshConfigAlias: v.sshConfigAlias ?? "",
|
||||
knownHostsEintragen: v.knownHostsEintragen,
|
||||
privatNachAusrollenLoeschen: v.privatNachAusrollenLoeschen,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||
import { LEERE_VORLAGE, VorlagenFormular } from "@/components/vorlagen/VorlagenFormular";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
export const metadata: Metadata = { title: "Vorlage anlegen" };
|
||||
|
||||
export default async function VorlageNeu() {
|
||||
// Vorlagen pflegen nur Admins (#019) – wie die uebrigen Einstellungen.
|
||||
await sitzungErforderlich("einstellungenVerwalten", "/vorlagen/neu");
|
||||
return (
|
||||
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
|
||||
<Seitenkopf
|
||||
oben={<Breadcrumb pfad={[{ titel: "Vorlagen", href: "/vorlagen" }, { titel: "Neue Vorlage" }]} />}
|
||||
titel="Vorlage anlegen"
|
||||
untertitel="Eine Vorlage füllt den Assistenten vor; im Einzelfall lässt sich alles noch ändern."
|
||||
/>
|
||||
<VorlagenFormular vorgabe={LEERE_VORLAGE} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,137 @@
|
||||
import { FileStack, Pencil, Plus } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
import { Fragment, type ReactNode } from "react";
|
||||
import { KategorieBadge } from "@/components/schluessel/Anzeige";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Karte } from "@/components/ui/Karte";
|
||||
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { vorlagenListe, type VorlagenListenEintrag } from "@/server/queries/vorlagen";
|
||||
|
||||
export const metadata: Metadata = { title: "Vorlagen" };
|
||||
|
||||
export default async function Vorlagen() {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
await sitzungErforderlich();
|
||||
const SUDO_TEXT = { KEINER: null, BEFEHLE: "sudo: bestimmte Befehle", VOLL: "sudo: voll" } as const;
|
||||
|
||||
function Angabe({ titel, children }: { titel: string; children: ReactNode }) {
|
||||
return (
|
||||
<Platzhalterseite
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<dt className="text-muted text-[12.5px] font-medium">{titel}</dt>
|
||||
<dd className="min-w-0 text-[14px]">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Mono({ wert }: { wert: string | null }) {
|
||||
if (!wert) return <span className="text-muted">–</span>;
|
||||
// Lange Optionslisten sollen auf 390 px nach einem Komma umbrechen, nicht mitten im Wort (#019).
|
||||
const teile = wert.split(",");
|
||||
return (
|
||||
<code className="text-text font-mono text-[13px] [overflow-wrap:anywhere]">
|
||||
{teile.map((teil, i) => (
|
||||
<Fragment key={i}>
|
||||
{teil}
|
||||
{i < teile.length - 1 && (
|
||||
<>
|
||||
,<wbr />
|
||||
</>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
function VorlagenKarte({ v, verwalten }: { v: VorlagenListenEintrag; verwalten: boolean }) {
|
||||
const sudo = SUDO_TEXT[v.sudoModus];
|
||||
return (
|
||||
<Karte
|
||||
as="article"
|
||||
titel={v.name}
|
||||
aktionen={
|
||||
verwalten && (
|
||||
<Button
|
||||
href={`/vorlagen/${v.id}`}
|
||||
klein
|
||||
icon={<Pencil className="size-3.5" />}
|
||||
aria-label={`${v.name} bearbeiten`}
|
||||
>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
className="flex flex-col"
|
||||
>
|
||||
<div className="-mt-2 mb-4 flex flex-wrap items-center gap-1.5">
|
||||
<KategorieBadge kategorie={v.kategorie} />
|
||||
<Badge mono>{v.schluesselTyp === "RSA4096" ? "rsa-4096" : "ed25519"}</Badge>
|
||||
{v.eingebaut && <Badge>eingebaut</Badge>}
|
||||
{v.standardLaufzeitTage && (
|
||||
<Badge variante="messing">
|
||||
{v.standardLaufzeitTage === 1 ? "1 Tag" : `${v.standardLaufzeitTage} Tage`}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{v.beschreibung && <p className="text-muted mb-4 text-[14px]">{v.beschreibung}</p>}
|
||||
<dl className="border-border mt-auto grid gap-3 border-t pt-4">
|
||||
<Angabe titel="Zielbenutzer">
|
||||
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<Mono wert={v.zielbenutzer} />
|
||||
{v.zielbenutzerAnlegen && <span className="text-muted text-[13px]">wird angelegt</span>}
|
||||
{sudo && (
|
||||
<Badge variante={v.sudoModus === "VOLL" ? "gefahr" : "neutral"}>
|
||||
{sudo}
|
||||
{v.sudoModus === "BEFEHLE" && ` (${v.sudoBefehle.length})`}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
</Angabe>
|
||||
<Angabe titel="authorized_keys-Optionen">
|
||||
<Mono wert={v.authorizedKeysOptionen} />
|
||||
</Angabe>
|
||||
<Angabe titel="Alias-Muster">
|
||||
<Mono wert={v.sshConfigAlias} />
|
||||
</Angabe>
|
||||
</dl>
|
||||
</Karte>
|
||||
);
|
||||
}
|
||||
|
||||
/** Vorlagen (#019): lesen darf jeder, anlegen und bearbeiten nur, wer Einstellungen verwaltet. */
|
||||
export default async function Vorlagen() {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
const { benutzer } = await sitzungErforderlich("lesen", "/vorlagen");
|
||||
const vorlagen = await vorlagenListe();
|
||||
const verwalten = darf(benutzer.rolle, "einstellungenVerwalten");
|
||||
const anlegen = verwalten && (
|
||||
<Button href="/vorlagen/neu" variante="primaer" icon={<Plus className="size-4" strokeWidth={2.4} />}>
|
||||
Vorlage anlegen
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
<Seitenkopf
|
||||
titel="Vorlagen"
|
||||
untertitel="Wiederkehrende Zugänge wie Deployment oder Backup"
|
||||
phase={3}
|
||||
issue="#019"
|
||||
untertitel="Wiederkehrende Zugänge wie Deployment oder Backup – sie füllen den Assistenten vor."
|
||||
aktionen={anlegen}
|
||||
/>
|
||||
{vorlagen.length === 0 ? (
|
||||
<Leerzustand
|
||||
icon={<FileStack className="size-5" />}
|
||||
titel="Keine Vorlagen"
|
||||
text="Die eingebauten Vorlagen legt der Seed an (pnpm db:seed)."
|
||||
aktion={anlegen}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-2 2xl:grid-cols-3">
|
||||
{vorlagen.map((v) => (
|
||||
<VorlagenKarte key={v.id} v={v} verwalten={verwalten} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, ArrowRight, Check, ExternalLink, Play } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { clsx } from "clsx";
|
||||
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
||||
import { Ankreuzfeld, Button, Feld, Formularfehler, Karte } from "@/components/ui";
|
||||
import { Auswahlliste, Textbereich } from "@/components/ui/Feld";
|
||||
import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
|
||||
import { istIpAdresse, platzhalterErsetzen } from "@/lib/vorlagen";
|
||||
import { sudoersInhalt } from "@/lib/ssh/zielkonto";
|
||||
import {
|
||||
assistentErzeugenAktion,
|
||||
assistentZielkontoAktion,
|
||||
assistentZuweisenAktion,
|
||||
type SchrittAntwort,
|
||||
} from "@/server/actions/assistent";
|
||||
import { verbindungTestenAktion } from "@/server/actions/verwendungen";
|
||||
import type { AssistentDaten } from "@/server/queries/assistent";
|
||||
|
||||
/**
|
||||
* Host-zu-Host-Assistent (#020). Vier Schritte Eingabe, dann eine Zusammenfassung, dann die
|
||||
* Ausfuehrung: Schluessel erzeugen → (Zielbenutzer anlegen) → zuweisen → Dry-Run und Ausrollen →
|
||||
* Verbindungstest. Die Ausfuehrung ist wiederaufnehmbar: was schon erledigt ist, wird nicht
|
||||
* wiederholt – wichtig, wenn mittendrin eine erneute Bestaetigung noetig wird.
|
||||
*/
|
||||
type Werte = {
|
||||
vorlageId: string;
|
||||
zweck: string;
|
||||
ablaufAm: string;
|
||||
quelleHostId: string;
|
||||
quelleBenutzer: string;
|
||||
pfad: string;
|
||||
zielHostId: string;
|
||||
zielBenutzer: string;
|
||||
anlegen: boolean;
|
||||
sudoModus: "KEINER" | "BEFEHLE" | "VOLL";
|
||||
sudoBefehle: string;
|
||||
optionen: string;
|
||||
quellIp: string;
|
||||
alias: string;
|
||||
hostName: string;
|
||||
port: string;
|
||||
knownHosts: boolean;
|
||||
fingerprintGeprueft: boolean;
|
||||
};
|
||||
|
||||
const LEER: Werte = {
|
||||
vorlageId: "",
|
||||
zweck: "",
|
||||
ablaufAm: "",
|
||||
quelleHostId: "",
|
||||
quelleBenutzer: "root",
|
||||
pfad: "",
|
||||
zielHostId: "",
|
||||
zielBenutzer: "root",
|
||||
anlegen: false,
|
||||
sudoModus: "KEINER",
|
||||
sudoBefehle: "",
|
||||
optionen: "",
|
||||
quellIp: "",
|
||||
alias: "",
|
||||
hostName: "",
|
||||
port: "22",
|
||||
knownHosts: true,
|
||||
fingerprintGeprueft: false,
|
||||
};
|
||||
|
||||
const SCHRITTE = ["Vorlage & Zweck", "Quelle (Host A)", "Ziel (Host B)", "Prüfen", "Ausrollen"] as const;
|
||||
|
||||
type Ablauf = {
|
||||
schluesselId?: string;
|
||||
zielkontoFertig?: boolean;
|
||||
privatVerwendungId?: string;
|
||||
laeuft?: string;
|
||||
fehler?: string;
|
||||
bestaetigen?: boolean;
|
||||
};
|
||||
|
||||
function datumIn(tage: number): string {
|
||||
return new Date(Date.now() + tage * 86_400_000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function kurz(text: string): string {
|
||||
return (
|
||||
text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.slice(0, 40) || "zugang"
|
||||
);
|
||||
}
|
||||
|
||||
/** Wartet auf das Ende eines Vorgangs (fuer die Kette im Klick, nicht als Hook). */
|
||||
async function warten(
|
||||
vorgangId: string,
|
||||
): Promise<{ status: string; fehler: string | null; ergebnis: unknown }> {
|
||||
for (;;) {
|
||||
const antwort = await fetch(`/api/vorgaenge/${vorgangId}`, { cache: "no-store" });
|
||||
if (!antwort.ok) throw new Error("Status des Vorgangs nicht abrufbar.");
|
||||
const stand = (await antwort.json()) as { status: string; fehler: string | null; ergebnis: unknown };
|
||||
if (stand.status !== "WARTEND" && stand.status !== "LAEUFT") return stand;
|
||||
await new Promise((weiter) => setTimeout(weiter, 800));
|
||||
}
|
||||
}
|
||||
|
||||
export function Assistent({ daten, bestaetigt }: { daten: AssistentDaten; bestaetigt: boolean }) {
|
||||
const [schritt, setSchritt] = useState(0);
|
||||
const [w, setW] = useState<Werte>(LEER);
|
||||
const [ablauf, setAblauf] = useState<Ablauf>({});
|
||||
const setze = <K extends keyof Werte>(feld: K, wert: Werte[K]) => setW((alt) => ({ ...alt, [feld]: wert }));
|
||||
|
||||
const vorlage = daten.vorlagen.find((v) => v.id === w.vorlageId);
|
||||
const quelle = daten.hosts.find((h) => h.id === w.quelleHostId);
|
||||
const ziel = daten.hosts.find((h) => h.id === w.zielHostId);
|
||||
|
||||
function vorlageWaehlen(id: string) {
|
||||
const v = daten.vorlagen.find((x) => x.id === id);
|
||||
setW((alt) => ({
|
||||
...alt,
|
||||
vorlageId: id,
|
||||
...(v
|
||||
? {
|
||||
zielBenutzer: v.zielbenutzer,
|
||||
anlegen: v.zielbenutzerAnlegen,
|
||||
sudoModus: v.sudoModus,
|
||||
sudoBefehle: v.sudoBefehle.join("\n"),
|
||||
knownHosts: v.knownHostsEintragen,
|
||||
ablaufAm: v.standardLaufzeitTage ? datumIn(v.standardLaufzeitTage) : "",
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Beim Wechsel zu „Ziel“: Muster der Vorlage mit A und B aufloesen (nur leere Felder). */
|
||||
function zielVorbelegen(zielId: string) {
|
||||
const b = daten.hosts.find((h) => h.id === zielId);
|
||||
const quellIp = w.quellIp || (quelle && istIpAdresse(quelle.adresse) ? quelle.adresse : "");
|
||||
const werte = {
|
||||
quellHost: quelle?.name,
|
||||
quellHostIp: quellIp,
|
||||
zielHost: b?.name,
|
||||
zielHostIp: b?.adresse,
|
||||
};
|
||||
const aufloesen = (muster: string | null | undefined) => {
|
||||
if (!muster) return "";
|
||||
try {
|
||||
return platzhalterErsetzen(muster, werte);
|
||||
} catch {
|
||||
return muster;
|
||||
}
|
||||
};
|
||||
setW((alt) => ({
|
||||
...alt,
|
||||
zielHostId: zielId,
|
||||
quellIp,
|
||||
optionen: alt.optionen || aufloesen(vorlage?.authorizedKeysOptionen),
|
||||
alias: alt.alias || aufloesen(vorlage?.sshConfigAlias) || (b ? b.name : ""),
|
||||
hostName: b ? b.adresse : "",
|
||||
port: b ? String(b.port) : "22",
|
||||
pfad: alt.pfad || `~/.ssh/id_ed25519_${kurz(alt.zweck)}`,
|
||||
fingerprintGeprueft: false,
|
||||
}));
|
||||
}
|
||||
|
||||
const offenePlatzhalter = /\{\{/.test(w.optionen) || /\{\{/.test(w.alias);
|
||||
let sudoVorschau: string | null = null;
|
||||
let sudoFehler: string | undefined;
|
||||
if (w.anlegen) {
|
||||
try {
|
||||
sudoVorschau = sudoersInhalt(
|
||||
w.zielBenutzer,
|
||||
w.sudoModus,
|
||||
w.sudoBefehle.split("\n").filter((z) => z.trim()),
|
||||
);
|
||||
} catch (fehler) {
|
||||
sudoFehler = (fehler as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
const schrittOk = [
|
||||
w.zweck.trim().length > 0,
|
||||
Boolean(w.quelleHostId && w.quelleBenutzer),
|
||||
Boolean(w.zielHostId && w.zielHostId !== w.quelleHostId && w.zielBenutzer && w.alias && w.hostName) &&
|
||||
!offenePlatzhalter &&
|
||||
!sudoFehler &&
|
||||
(!w.knownHosts || w.fingerprintGeprueft),
|
||||
true,
|
||||
];
|
||||
|
||||
async function ausfuehren() {
|
||||
let stand = { ...ablauf, fehler: undefined, bestaetigen: false } as Ablauf;
|
||||
const melden = (neu: Partial<Ablauf>) => {
|
||||
stand = { ...stand, ...neu };
|
||||
setAblauf(stand);
|
||||
};
|
||||
const pruefen = (antwort: SchrittAntwort) => {
|
||||
if (antwort.fehler || antwort.bestaetigen) {
|
||||
melden({ laeuft: undefined, fehler: antwort.fehler, bestaetigen: Boolean(antwort.bestaetigen) });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
try {
|
||||
if (!stand.schluesselId) {
|
||||
melden({ laeuft: "Schlüsselpaar wird erzeugt …" });
|
||||
const antwort = await assistentErzeugenAktion({
|
||||
name: w.zweck.trim(),
|
||||
vorlageId: w.vorlageId || null,
|
||||
ablaufAm: w.ablaufAm || null,
|
||||
});
|
||||
if (!pruefen(antwort)) return;
|
||||
const ergebnis = await warten(antwort.vorgangId!);
|
||||
if (ergebnis.status !== "ERLEDIGT")
|
||||
return melden({ laeuft: undefined, fehler: ergebnis.fehler ?? "Erzeugen fehlgeschlagen." });
|
||||
melden({ schluesselId: (ergebnis.ergebnis as { schluesselId: string }).schluesselId });
|
||||
}
|
||||
if (w.anlegen && !stand.zielkontoFertig) {
|
||||
melden({ laeuft: `Benutzer ${w.zielBenutzer} wird auf ${ziel?.name} eingerichtet …` });
|
||||
const antwort = await assistentZielkontoAktion({
|
||||
hostId: w.zielHostId,
|
||||
benutzer: w.zielBenutzer,
|
||||
modus: w.sudoModus,
|
||||
befehle: w.sudoBefehle.split("\n").filter((z) => z.trim()),
|
||||
});
|
||||
if (!pruefen(antwort)) return;
|
||||
const ergebnis = await warten(antwort.vorgangId!);
|
||||
if (ergebnis.status !== "ERLEDIGT")
|
||||
return melden({ laeuft: undefined, fehler: ergebnis.fehler ?? "Zielkonto fehlgeschlagen." });
|
||||
melden({ zielkontoFertig: true });
|
||||
}
|
||||
if (!stand.privatVerwendungId) {
|
||||
melden({ laeuft: "Zuweisungen werden angelegt …" });
|
||||
const antwort = await assistentZuweisenAktion({
|
||||
schluesselId: stand.schluesselId!,
|
||||
quelle: { hostId: w.quelleHostId, benutzer: w.quelleBenutzer, pfad: w.pfad },
|
||||
ziel: { hostId: w.zielHostId, benutzer: w.zielBenutzer, optionen: w.optionen.trim() || null },
|
||||
ssh: { alias: w.alias, hostName: w.hostName, port: Number(w.port), knownHosts: w.knownHosts },
|
||||
});
|
||||
if (!pruefen(antwort)) return;
|
||||
melden({ privatVerwendungId: antwort.privatVerwendungId });
|
||||
}
|
||||
melden({ laeuft: undefined });
|
||||
setSchritt(4);
|
||||
} catch (fehler) {
|
||||
melden({ laeuft: undefined, fehler: (fehler as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<ol className="flex flex-wrap gap-2" aria-label="Schritte">
|
||||
{SCHRITTE.map((titel, index) => (
|
||||
<li
|
||||
key={titel}
|
||||
aria-current={index === schritt ? "step" : undefined}
|
||||
className={clsx(
|
||||
"rounded-box flex min-h-11 items-center gap-2 border px-3 text-[13.5px]",
|
||||
index === schritt
|
||||
? "border-accent text-accent bg-raised font-medium"
|
||||
: index < schritt
|
||||
? "border-border text-text"
|
||||
: "border-border text-muted",
|
||||
)}
|
||||
>
|
||||
<span className="font-mono">
|
||||
{index < schritt ? <Check className="size-4" aria-hidden /> : index + 1}
|
||||
</span>
|
||||
{titel}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{schritt === 0 && (
|
||||
<Karte
|
||||
titel="Vorlage & Zweck"
|
||||
untertitel="Eine Vorlage füllt Zielbenutzer, Einschränkungen und Ablauf vor."
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Auswahlliste
|
||||
beschriftung="Vorlage"
|
||||
name="vorlageId"
|
||||
value={w.vorlageId}
|
||||
onChange={(e) => vorlageWaehlen(e.target.value)}
|
||||
>
|
||||
<option value="">– ohne Vorlage –</option>
|
||||
{daten.vorlagen.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
</Auswahlliste>
|
||||
{vorlage?.beschreibung && <p className="text-muted text-[14px]">{vorlage.beschreibung}</p>}
|
||||
<Feld
|
||||
beschriftung="Zweck"
|
||||
name="zweck"
|
||||
value={w.zweck}
|
||||
onChange={(e) => setze("zweck", e.target.value)}
|
||||
placeholder="Backup srv-backup01 → srv-pve01"
|
||||
required
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Ablaufdatum (optional)"
|
||||
name="ablaufAm"
|
||||
type="date"
|
||||
value={w.ablaufAm}
|
||||
onChange={(e) => setze("ablaufAm", e.target.value)}
|
||||
hinweis="Projektbezogene Zugänge sollten ablaufen."
|
||||
/>
|
||||
</div>
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
{schritt === 1 && (
|
||||
<Karte
|
||||
titel="Quelle – Host A"
|
||||
untertitel="Dieser Host bekommt den Private Key und meldet sich später an."
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Auswahlliste
|
||||
beschriftung="Host A"
|
||||
name="quelleHostId"
|
||||
value={w.quelleHostId}
|
||||
onChange={(e) => setze("quelleHostId", e.target.value)}
|
||||
>
|
||||
<option value="">Host wählen …</option>
|
||||
{daten.hosts.map((h) => (
|
||||
<option key={h.id} value={h.id}>
|
||||
{h.name}
|
||||
</option>
|
||||
))}
|
||||
</Auswahlliste>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld
|
||||
beschriftung="Benutzer auf A"
|
||||
name="quelleBenutzer"
|
||||
value={w.quelleBenutzer}
|
||||
onChange={(e) => setze("quelleBenutzer", e.target.value)}
|
||||
mono
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Pfad des Private Keys"
|
||||
name="pfad"
|
||||
value={w.pfad || `~/.ssh/id_ed25519_${kurz(w.zweck)}`}
|
||||
onChange={(e) => setze("pfad", e.target.value)}
|
||||
mono
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
{schritt === 2 && (
|
||||
<Karte titel="Ziel – Host B" untertitel="Dieser Host nimmt den Public Key an.">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Auswahlliste
|
||||
beschriftung="Host B"
|
||||
name="zielHostId"
|
||||
value={w.zielHostId}
|
||||
onChange={(e) => zielVorbelegen(e.target.value)}
|
||||
>
|
||||
<option value="">Host wählen …</option>
|
||||
{daten.hosts
|
||||
.filter((h) => h.id !== w.quelleHostId)
|
||||
.map((h) => (
|
||||
<option key={h.id} value={h.id}>
|
||||
{h.name}
|
||||
</option>
|
||||
))}
|
||||
</Auswahlliste>
|
||||
<Feld
|
||||
beschriftung="Benutzer auf B"
|
||||
name="zielBenutzer"
|
||||
value={w.zielBenutzer}
|
||||
onChange={(e) => setze("zielBenutzer", e.target.value)}
|
||||
mono
|
||||
/>
|
||||
{w.zielBenutzer === "root" && (
|
||||
<p className="border-danger-border bg-danger-soft text-danger rounded-box border px-3 py-2.5 text-[13.5px]">
|
||||
▲ Zugang als root: Wer den Private Key auf A nutzt – etwa ein KI-Agent wie Claude Code –, hat
|
||||
auf B uneingeschränkten Zugriff. Für Deployments ist ein eigener Benutzer wie „deploy“ besser.
|
||||
</p>
|
||||
)}
|
||||
<Ankreuzfeld
|
||||
beschriftung={`Benutzer „${w.zielBenutzer}“ auf B anlegen bzw. pflegen`}
|
||||
erklaerung="Mit Home und ~/.ssh; optional mit sudoers-Datei. Verlangt eine frische Bestätigung."
|
||||
checked={w.anlegen}
|
||||
onChange={(e) => setze("anlegen", e.target.checked)}
|
||||
/>
|
||||
{w.anlegen && (
|
||||
<div className="border-border flex flex-col gap-3 border-l-2 pl-4">
|
||||
<Auswahlliste
|
||||
beschriftung="sudo"
|
||||
name="sudoModus"
|
||||
value={w.sudoModus}
|
||||
onChange={(e) => setze("sudoModus", e.target.value as Werte["sudoModus"])}
|
||||
>
|
||||
<option value="KEINER">keine sudo-Rechte</option>
|
||||
<option value="BEFEHLE">bestimmte Befehle</option>
|
||||
<option value="VOLL">voller sudo-Zugriff</option>
|
||||
</Auswahlliste>
|
||||
{w.sudoModus === "BEFEHLE" && (
|
||||
<Textbereich
|
||||
beschriftung="Erlaubte Befehle (einer je Zeile, absoluter Pfad)"
|
||||
name="sudoBefehle"
|
||||
value={w.sudoBefehle}
|
||||
onChange={(e) => setze("sudoBefehle", e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
)}
|
||||
{sudoFehler && <p className="text-danger text-[13px]">{sudoFehler}</p>}
|
||||
{sudoVorschau && (
|
||||
<pre className="border-border bg-bg rounded-box overflow-x-auto border p-3 font-mono text-[12.5px]">
|
||||
{sudoVorschau}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{quelle && !istIpAdresse(quelle.adresse) && (
|
||||
<Feld
|
||||
beschriftung={`IP von ${quelle.name} (für from="…")`}
|
||||
name="quellIp"
|
||||
value={w.quellIp}
|
||||
onChange={(e) => setze("quellIp", e.target.value)}
|
||||
mono
|
||||
hinweis="Die Adresse, unter der B den Host A sieht."
|
||||
/>
|
||||
)}
|
||||
<Feld
|
||||
beschriftung="Einschränkungen in authorized_keys"
|
||||
name="optionen"
|
||||
value={w.optionen}
|
||||
onChange={(e) => setze("optionen", e.target.value)}
|
||||
mono
|
||||
placeholder={
|
||||
quelle && istIpAdresse(quelle.adresse) ? `from="${quelle.adresse}"` : 'from="10.0.0.5"'
|
||||
}
|
||||
hinweis={
|
||||
offenePlatzhalter
|
||||
? "Es sind noch Platzhalter offen."
|
||||
: 'Empfohlen: from="<IP von A>", damit der Schlüssel nur von dort funktioniert.'
|
||||
}
|
||||
/>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Feld
|
||||
beschriftung="Alias auf A"
|
||||
name="alias"
|
||||
value={w.alias}
|
||||
onChange={(e) => setze("alias", e.target.value)}
|
||||
mono
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="HostName (aus Sicht von A)"
|
||||
name="hostName"
|
||||
value={w.hostName}
|
||||
onChange={(e) => setze("hostName", e.target.value)}
|
||||
mono
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Port"
|
||||
name="port"
|
||||
type="number"
|
||||
value={w.port}
|
||||
onChange={(e) => setze("port", e.target.value)}
|
||||
mono
|
||||
/>
|
||||
</div>
|
||||
<Ankreuzfeld
|
||||
beschriftung="Host-Key von B in known_hosts von A eintragen"
|
||||
checked={w.knownHosts}
|
||||
onChange={(e) => setze("knownHosts", e.target.checked)}
|
||||
/>
|
||||
{w.knownHosts && ziel && (
|
||||
<div className="border-border rounded-box flex flex-col gap-2 border p-3">
|
||||
{ziel.hostKeys.map((k) => (
|
||||
<p key={k.fingerprint} className="font-mono text-[12.5px] break-all">
|
||||
<span className="text-muted">{k.algorithmus}</span> {k.fingerprint}
|
||||
</p>
|
||||
))}
|
||||
<Ankreuzfeld
|
||||
beschriftung="Fingerprint von B ist bestätigt"
|
||||
erklaerung="Derselbe Host-Key, dem sshkv beim Bootstrap von B vertraut hat."
|
||||
checked={w.fingerprintGeprueft}
|
||||
onChange={(e) => setze("fingerprintGeprueft", e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
{schritt === 3 && (
|
||||
<Karte
|
||||
titel="Zusammenfassung"
|
||||
untertitel="Noch ist nichts passiert. Ausgerollt wird erst nach dem Dry-Run."
|
||||
>
|
||||
<dl className="grid gap-x-6 gap-y-3 text-[14px] sm:grid-cols-[12rem_minmax(0,1fr)]">
|
||||
<dt className="text-muted">Schlüssel</dt>
|
||||
<dd>
|
||||
ed25519 · {w.zweck}
|
||||
{w.ablaufAm && (
|
||||
<span className="text-accent">
|
||||
{" "}
|
||||
· läuft ab am {w.ablaufAm.split("-").reverse().join(".")}
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
<dt className="text-muted">Private Key</dt>
|
||||
<dd className="font-mono text-[13px] break-all">
|
||||
{w.quelleBenutzer}@{quelle?.name}:{w.pfad}
|
||||
</dd>
|
||||
<dt className="text-muted">~/.ssh/config auf A</dt>
|
||||
<dd className="font-mono text-[13px] break-all">
|
||||
Host {w.alias} → {w.zielBenutzer}@{w.hostName}:{w.port}
|
||||
</dd>
|
||||
<dt className="text-muted">Public Key</dt>
|
||||
<dd className="font-mono text-[13px] break-all">
|
||||
{w.optionen && `${w.optionen} · `}
|
||||
{w.zielBenutzer}@{ziel?.name}
|
||||
</dd>
|
||||
{w.anlegen && (
|
||||
<>
|
||||
<dt className="text-muted">Zielbenutzer</dt>
|
||||
<dd>
|
||||
„{w.zielBenutzer}“ auf {ziel?.name} anlegen,{" "}
|
||||
{w.sudoModus === "KEINER" ? (
|
||||
"ohne sudo"
|
||||
) : w.sudoModus === "VOLL" ? (
|
||||
<span className="text-danger">voller sudo-Zugriff</span>
|
||||
) : (
|
||||
"sudo für bestimmte Befehle"
|
||||
)}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
{w.anlegen && !bestaetigt && !ablauf.bestaetigen && (
|
||||
<p className="text-accent mt-4 text-[13.5px]">
|
||||
Das Anlegen des Benutzers verlangt eine frische Bestätigung Ihrer Anmeldung – sshkv fragt beim
|
||||
Ausführen danach.
|
||||
</p>
|
||||
)}
|
||||
<div className="border-border mt-5 flex flex-col gap-3 border-t pt-4">
|
||||
<Formularfehler text={ablauf.fehler} />
|
||||
{ablauf.bestaetigen && (
|
||||
<a
|
||||
href="/bestaetigen?ziel=%2Fassistent%2Fbestaetigt"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="text-accent inline-flex min-h-11 items-center gap-2 text-[14px] font-medium"
|
||||
>
|
||||
<ExternalLink className="size-4" aria-hidden /> Anmeldung in neuem Tab bestätigen, dann hier
|
||||
erneut ausführen
|
||||
</a>
|
||||
)}
|
||||
{ablauf.laeuft && (
|
||||
<p role="status" className="text-muted text-[14px]">
|
||||
{ablauf.laeuft}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variante="primaer"
|
||||
icon={<Play className="size-4" />}
|
||||
disabled={Boolean(ablauf.laeuft)}
|
||||
onClick={() => void ausfuehren()}
|
||||
>
|
||||
{ablauf.schluesselId ? "Fortsetzen" : "Vorbereiten"}
|
||||
</Button>
|
||||
</div>
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
{schritt === 4 && ablauf.schluesselId && (
|
||||
<>
|
||||
<Karte
|
||||
titel="Dry-Run und Ausrollen"
|
||||
untertitel="Prüfen Sie die Änderungen auf A und B, dann anwenden."
|
||||
>
|
||||
<AusrollenPanel auswahl={{ schluesselId: ablauf.schluesselId }} offen={2} />
|
||||
</Karte>
|
||||
<Karte
|
||||
titel="Verbindung testen"
|
||||
untertitel={`Auf ${quelle?.name} als ${w.quelleBenutzer}: ssh ${w.alias} true`}
|
||||
>
|
||||
{ablauf.privatVerwendungId && (
|
||||
<VorgangsKnopf
|
||||
aktion={verbindungTestenAktion.bind(null, ablauf.privatVerwendungId)}
|
||||
beschriftung="Verbindung von A nach B testen"
|
||||
variante="primaer"
|
||||
/>
|
||||
)}
|
||||
<p className="text-muted mt-3 text-[13.5px]">
|
||||
Zum Schlüssel:{" "}
|
||||
<a className="text-accent" href={`/schluessel/${ablauf.schluesselId}`}>
|
||||
{w.zweck}
|
||||
</a>
|
||||
</p>
|
||||
</Karte>
|
||||
</>
|
||||
)}
|
||||
|
||||
{schritt < 3 && (
|
||||
<div className="flex flex-wrap justify-between gap-3">
|
||||
<Button
|
||||
icon={<ArrowLeft className="size-4" />}
|
||||
disabled={schritt === 0}
|
||||
onClick={() => setSchritt(schritt - 1)}
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
<Button
|
||||
variante="primaer"
|
||||
icon={<ArrowRight className="size-4" />}
|
||||
disabled={!schrittOk[schritt]}
|
||||
onClick={() => {
|
||||
if (schritt === 1 && !w.pfad) setze("pfad", `~/.ssh/id_ed25519_${kurz(w.zweck)}`);
|
||||
setSchritt(schritt + 1);
|
||||
}}
|
||||
>
|
||||
Weiter
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{schritt === 3 && !ablauf.schluesselId && (
|
||||
<div>
|
||||
<Button
|
||||
icon={<ArrowLeft className="size-4" />}
|
||||
disabled={Boolean(ablauf.laeuft)}
|
||||
onClick={() => setSchritt(2)}
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { Archive } from "lucide-react";
|
||||
import { useActionState, useRef } from "react";
|
||||
import { Button, Formularfehler } from "@/components/ui";
|
||||
import type { FormularZustand } from "@/server/actions/formular";
|
||||
import { vorlageArchivierenAktion } from "@/server/actions/vorlagen";
|
||||
|
||||
/** Archivieren mit Rueckfrage (#019), gleiches Muster wie bei Hosts (#008). */
|
||||
export function VorlageArchivierenDialog({ vorlageId, name }: { vorlageId: string; name: string }) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(vorlageArchivierenAktion, {});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variante="gefahr"
|
||||
icon={<Archive className="size-4" />}
|
||||
onClick={() => dialog.current?.showModal()}
|
||||
>
|
||||
Vorlage archivieren
|
||||
</Button>
|
||||
<dialog
|
||||
ref={dialog}
|
||||
aria-labelledby="archivieren-titel"
|
||||
className="border-border-strong bg-surface text-text rounded-karte m-auto w-[calc(100%-32px)] max-w-md border p-5 backdrop:bg-black/70 sm:p-6"
|
||||
>
|
||||
<form action={aktion} className="flex flex-col gap-4">
|
||||
<input type="hidden" name="id" value={vorlageId} />
|
||||
<h2 id="archivieren-titel" className="text-[17px] font-semibold">
|
||||
„{name}“ archivieren?
|
||||
</h2>
|
||||
<p className="text-muted text-[14.5px]">
|
||||
Die Vorlage steht im Assistenten nicht mehr zur Auswahl. Schlüssel, die mit ihr angelegt wurden,
|
||||
bleiben unverändert; das Protokoll bleibt erhalten.
|
||||
</p>
|
||||
<Formularfehler text={zustand.fehler} />
|
||||
<div className="flex flex-wrap justify-end gap-2.5">
|
||||
<Button onClick={() => dialog.current?.close()}>Abbrechen</Button>
|
||||
<Button type="submit" variante="gefahr" disabled={laeuft}>
|
||||
{laeuft ? "Archiviere …" : "Archivieren"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
"use client";
|
||||
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useFormularAktion } from "@/components/hosts/useFormularAktion";
|
||||
import { Ankreuzfeld, Button, Feld, Feldgruppe, Formularfehler, Karte, Textbereich } from "@/components/ui";
|
||||
import { MonoBlock } from "@/components/ui/MonoBlock";
|
||||
import { optionenPruefen } from "@/lib/ssh/optionen";
|
||||
import { sudoersInhalt } from "@/lib/ssh/zielkonto";
|
||||
import { PLATZHALTER, platzhalterErsetzen, type PlatzhalterWerte } from "@/lib/vorlagen";
|
||||
import { vorlageSpeichernAktion } from "@/server/actions/vorlagen";
|
||||
|
||||
type SudoModus = "KEINER" | "BEFEHLE" | "VOLL";
|
||||
|
||||
export type VorlageFormularVorgabe = {
|
||||
id?: string;
|
||||
name: string;
|
||||
beschreibung: string;
|
||||
schluesselTyp: "ED25519" | "RSA4096";
|
||||
kategorie: "DIENST" | "PERSON";
|
||||
standardLaufzeitTage: number | null;
|
||||
zielbenutzer: string;
|
||||
zielbenutzerAnlegen: boolean;
|
||||
sudoModus: SudoModus;
|
||||
sudoBefehle: string[];
|
||||
authorizedKeysOptionen: string;
|
||||
sshConfigAlias: string;
|
||||
knownHostsEintragen: boolean;
|
||||
privatNachAusrollenLoeschen: boolean;
|
||||
};
|
||||
|
||||
export const LEERE_VORLAGE: VorlageFormularVorgabe = {
|
||||
name: "",
|
||||
beschreibung: "",
|
||||
schluesselTyp: "ED25519",
|
||||
kategorie: "DIENST",
|
||||
standardLaufzeitTage: null,
|
||||
zielbenutzer: "",
|
||||
zielbenutzerAnlegen: false,
|
||||
sudoModus: "KEINER",
|
||||
sudoBefehle: [],
|
||||
authorizedKeysOptionen: "",
|
||||
sshConfigAlias: "",
|
||||
knownHostsEintragen: true,
|
||||
privatNachAusrollenLoeschen: false,
|
||||
};
|
||||
|
||||
/** Beispielwerte der Vorschau – sichtbar andere als die Probewerte des Servers. */
|
||||
const BEISPIEL: Required<PlatzhalterWerte> = {
|
||||
quellHost: "srv-build01",
|
||||
quellHostIp: "10.0.0.5",
|
||||
zielHost: "srv-web01",
|
||||
zielHostIp: "10.0.0.20",
|
||||
};
|
||||
|
||||
type Vorschau = { wert: string | null; fehler?: string };
|
||||
|
||||
/**
|
||||
* Die Vorschau ist nur Anzeige (#019): Fehler werden zum Hinweis, nicht zum Absturz – die
|
||||
* verbindliche Pruefung macht der Server beim Speichern mit denselben Funktionen.
|
||||
*/
|
||||
function vorschau(berechnen: () => string | null): Vorschau {
|
||||
try {
|
||||
return { wert: berechnen() };
|
||||
} catch (fehler) {
|
||||
return { wert: null, fehler: fehler instanceof Error ? fehler.message : String(fehler) };
|
||||
}
|
||||
}
|
||||
|
||||
function befehlsZeilen(text: string): string[] {
|
||||
return text
|
||||
.split("\n")
|
||||
.map((b) => b.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function Vorschauzeile({ titel, ergebnis, leer }: { titel: string; ergebnis: Vorschau; leer: string }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<dt className="text-muted text-[13px] font-medium">{titel}</dt>
|
||||
<dd className="min-w-0">
|
||||
{ergebnis.fehler ? (
|
||||
<span className="text-danger text-[13.5px]">{ergebnis.fehler}</span>
|
||||
) : ergebnis.wert ? (
|
||||
<code className="text-text font-mono text-[13px] break-all">{ergebnis.wert}</code>
|
||||
) : (
|
||||
<span className="text-muted text-[13.5px]">{leer}</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Formular fuer neue und bestehende Vorlagen (#019). Regeln prueft der Server. */
|
||||
export function VorlagenFormular({ vorgabe }: { vorgabe: VorlageFormularVorgabe }) {
|
||||
const bearbeiten = Boolean(vorgabe.id);
|
||||
const { zustand, absenden, laeuft } = useFormularAktion(vorlageSpeichernAktion);
|
||||
const f = zustand.feldfehler ?? {};
|
||||
|
||||
// Kontrolliert nur, was die Vorschau braucht; der Rest bleibt unkontrolliert.
|
||||
const [zielbenutzer, setZielbenutzer] = useState(vorgabe.zielbenutzer);
|
||||
const [sudoModus, setSudoModus] = useState<SudoModus>(vorgabe.sudoModus);
|
||||
const [befehle, setBefehle] = useState(vorgabe.sudoBefehle.join("\n"));
|
||||
const [optionen, setOptionen] = useState(vorgabe.authorizedKeysOptionen);
|
||||
const [alias, setAlias] = useState(vorgabe.sshConfigAlias);
|
||||
|
||||
const optionenVorschau = vorschau(() =>
|
||||
optionen.trim() ? optionenPruefen(platzhalterErsetzen(optionen.trim(), BEISPIEL)) || null : null,
|
||||
);
|
||||
const aliasVorschau = vorschau(() => (alias.trim() ? platzhalterErsetzen(alias.trim(), BEISPIEL) : null));
|
||||
const sudoersVorschau = vorschau(() =>
|
||||
sudoModus === "KEINER" ? null : sudoersInhalt(zielbenutzer.trim(), sudoModus, befehlsZeilen(befehle)),
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={absenden} noValidate className="flex flex-col gap-6">
|
||||
{vorgabe.id && <input type="hidden" name="id" value={vorgabe.id} />}
|
||||
<Formularfehler text={zustand.fehler} />
|
||||
|
||||
<Karte titel="Allgemein">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Feld
|
||||
beschriftung="Name"
|
||||
name="name"
|
||||
defaultValue={vorgabe.name}
|
||||
required
|
||||
maxLength={80}
|
||||
autoComplete="off"
|
||||
placeholder="z. B. Monitoring-Zugang"
|
||||
fehler={f["name"]}
|
||||
/>
|
||||
<Textbereich
|
||||
beschriftung="Beschreibung (optional)"
|
||||
name="beschreibung"
|
||||
defaultValue={vorgabe.beschreibung}
|
||||
maxLength={500}
|
||||
fehler={f["beschreibung"]}
|
||||
/>
|
||||
</div>
|
||||
</Karte>
|
||||
|
||||
<Karte titel="Schlüssel">
|
||||
<div className="flex flex-col gap-5">
|
||||
<Feldgruppe legende="Typ" fehler={f["schluesselTyp"]}>
|
||||
<div className="grid gap-x-4 sm:grid-cols-2">
|
||||
<Ankreuzfeld
|
||||
type="radio"
|
||||
name="schluesselTyp"
|
||||
value="ED25519"
|
||||
defaultChecked={vorgabe.schluesselTyp === "ED25519"}
|
||||
beschriftung={<span className="font-mono">ed25519</span>}
|
||||
erklaerung="Empfohlen – kurz, schnell, sicher."
|
||||
/>
|
||||
<Ankreuzfeld
|
||||
type="radio"
|
||||
name="schluesselTyp"
|
||||
value="RSA4096"
|
||||
defaultChecked={vorgabe.schluesselTyp === "RSA4096"}
|
||||
beschriftung={<span className="font-mono">rsa-4096</span>}
|
||||
erklaerung="Nur für alte Gegenstellen ohne ed25519."
|
||||
/>
|
||||
</div>
|
||||
</Feldgruppe>
|
||||
<Feldgruppe legende="Kategorie" fehler={f["kategorie"]}>
|
||||
<div className="grid gap-x-4 sm:grid-cols-2">
|
||||
<Ankreuzfeld
|
||||
type="radio"
|
||||
name="kategorie"
|
||||
value="DIENST"
|
||||
defaultChecked={vorgabe.kategorie === "DIENST"}
|
||||
beschriftung="Dienst"
|
||||
erklaerung="Host-zu-Host oder Automatisierung; sshkv verwahrt den Private Key."
|
||||
/>
|
||||
<Ankreuzfeld
|
||||
type="radio"
|
||||
name="kategorie"
|
||||
value="PERSON"
|
||||
defaultChecked={vorgabe.kategorie === "PERSON"}
|
||||
beschriftung="Person"
|
||||
erklaerung="Der Private Key bleibt bei der Person; kein Benutzer anlegen, kein sudo."
|
||||
/>
|
||||
</div>
|
||||
</Feldgruppe>
|
||||
<Feld
|
||||
beschriftung="Standard-Laufzeit in Tagen (optional)"
|
||||
name="standardLaufzeitTage"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={3650}
|
||||
defaultValue={vorgabe.standardLaufzeitTage ?? ""}
|
||||
hinweis="Leer lassen für Schlüssel ohne Ablaufdatum."
|
||||
fehler={f["standardLaufzeitTage"]}
|
||||
className="sm:max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
</Karte>
|
||||
|
||||
<Karte titel="Zielkonto" untertitel="Unter welchem Benutzer der Schlüssel auf dem Zielhost landet">
|
||||
<div className="flex flex-col gap-5">
|
||||
<Feld
|
||||
beschriftung="Zielbenutzer"
|
||||
name="zielbenutzer"
|
||||
value={zielbenutzer}
|
||||
onChange={(e) => setZielbenutzer(e.target.value)}
|
||||
mono
|
||||
required
|
||||
maxLength={32}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="deploy"
|
||||
hinweis="Kleinbuchstaben, Ziffern, _ und -, höchstens 32 Zeichen."
|
||||
fehler={f["zielbenutzer"]}
|
||||
className="sm:max-w-xs"
|
||||
/>
|
||||
<Ankreuzfeld
|
||||
name="zielbenutzerAnlegen"
|
||||
value="ja"
|
||||
defaultChecked={vorgabe.zielbenutzerAnlegen}
|
||||
beschriftung="Benutzer anlegen, falls er fehlt"
|
||||
erklaerung="sshkv legt ihn beim Ausrollen an und entfernt ihn beim Widerruf wieder."
|
||||
/>
|
||||
<Feldgruppe
|
||||
legende="sudo-Rechte"
|
||||
hinweis="Nur für Benutzer, die sshkv selbst anlegt."
|
||||
fehler={f["sudoModus"]}
|
||||
>
|
||||
<div className="grid gap-x-4 sm:grid-cols-3">
|
||||
{(
|
||||
[
|
||||
["KEINER", "keine"],
|
||||
["BEFEHLE", "bestimmte Befehle"],
|
||||
["VOLL", "voll"],
|
||||
] as const
|
||||
).map(([wert, text]) => (
|
||||
<Ankreuzfeld
|
||||
key={wert}
|
||||
type="radio"
|
||||
name="sudoModus"
|
||||
value={wert}
|
||||
checked={sudoModus === wert}
|
||||
onChange={() => setSudoModus(wert)}
|
||||
beschriftung={text}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Feldgruppe>
|
||||
{/* Ausgeblendet statt entfernt, damit die Befehle beim Hin- und Herschalten bleiben. */}
|
||||
<Textbereich
|
||||
beschriftung="Erlaubte Befehle"
|
||||
name="sudoBefehle"
|
||||
value={befehle}
|
||||
onChange={(e) => setBefehle(e.target.value)}
|
||||
rows={4}
|
||||
spellCheck={false}
|
||||
placeholder={"/usr/bin/systemctl restart nginx\n/usr/bin/systemctl reload nginx"}
|
||||
hinweis="Ein Befehl je Zeile, mit absolutem Pfad. Argumente sind erlaubt, Platzhalter nicht."
|
||||
fehler={f["sudoBefehle"]}
|
||||
className={sudoModus === "BEFEHLE" ? "[&_textarea]:font-mono [&_textarea]:text-[13px]" : "hidden"}
|
||||
/>
|
||||
{sudoModus === "VOLL" && (
|
||||
<p
|
||||
role="note"
|
||||
className="border-danger-border bg-danger-soft text-danger rounded-box flex items-start gap-2.5 border px-3 py-2.5 text-[14px]"
|
||||
>
|
||||
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||
<span>
|
||||
Voller sudo-Zugang ohne Passwort: Wer diesen Schlüssel hat, ist auf dem Zielhost faktisch{" "}
|
||||
<strong className="font-semibold">root</strong>. Nur wählen, wenn bestimmte Befehle nicht
|
||||
reichen.
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Karte>
|
||||
|
||||
<Karte titel="authorized_keys & ssh">
|
||||
<div className="flex flex-col gap-5">
|
||||
<Feld
|
||||
beschriftung="authorized_keys-Optionen (optional)"
|
||||
name="authorizedKeysOptionen"
|
||||
value={optionen}
|
||||
onChange={(e) => setOptionen(e.target.value)}
|
||||
mono
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder={'from="{{quellHostIp}}",no-port-forwarding'}
|
||||
fehler={f["authorizedKeysOptionen"]}
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Alias-Muster für ~/.ssh/config (optional)"
|
||||
name="sshConfigAlias"
|
||||
value={alias}
|
||||
onChange={(e) => setAlias(e.target.value)}
|
||||
mono
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="deploy-{{zielHost}}"
|
||||
fehler={f["sshConfigAlias"]}
|
||||
/>
|
||||
<div className="border-border bg-sidebar rounded-box text-muted border px-4 py-3 text-[13px]">
|
||||
<p>
|
||||
Platzhalter:{" "}
|
||||
{PLATZHALTER.map((p, i) => (
|
||||
<span key={p}>
|
||||
{i > 0 && ", "}
|
||||
<code className="text-text font-mono whitespace-nowrap">{`{{${p}}}`}</code>
|
||||
</span>
|
||||
))}
|
||||
. Beim Ausrollen setzt sshkv Name und IP von Quell- und Zielhost ein.
|
||||
</p>
|
||||
<p className="mt-1.5">
|
||||
Beispiel: <code className="text-text font-mono break-all">{'from="{{quellHostIp}}"'}</code> wird
|
||||
zu <code className="text-text font-mono break-all">{`from="${BEISPIEL.quellHostIp}"`}</code>.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<Ankreuzfeld
|
||||
name="knownHostsEintragen"
|
||||
value="ja"
|
||||
defaultChecked={vorgabe.knownHostsEintragen}
|
||||
beschriftung="Host-Key des Ziels in known_hosts der Quelle eintragen"
|
||||
/>
|
||||
<Ankreuzfeld
|
||||
name="privatNachAusrollenLoeschen"
|
||||
value="ja"
|
||||
defaultChecked={vorgabe.privatNachAusrollenLoeschen}
|
||||
beschriftung="Private Key nach dem Ausrollen in sshkv löschen"
|
||||
erklaerung="Dann liegt er nur noch auf dem Quellhost und lässt sich nicht erneut ausrollen."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Karte>
|
||||
|
||||
<Karte
|
||||
titel="Vorschau"
|
||||
untertitel={
|
||||
<>
|
||||
Mit Beispielwerten: Quelle <span className="font-mono">{BEISPIEL.quellHost}</span> (
|
||||
<span className="font-mono">{BEISPIEL.quellHostIp}</span>), Ziel{" "}
|
||||
<span className="font-mono">{BEISPIEL.zielHost}</span> (
|
||||
<span className="font-mono">{BEISPIEL.zielHostIp}</span>)
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div aria-live="polite" className="flex flex-col gap-4">
|
||||
<dl className="grid gap-4 sm:grid-cols-2">
|
||||
<Vorschauzeile titel="authorized_keys-Optionen" ergebnis={optionenVorschau} leer="keine" />
|
||||
<Vorschauzeile titel="Alias" ergebnis={aliasVorschau} leer="kein Eintrag in ~/.ssh/config" />
|
||||
</dl>
|
||||
{sudoModus !== "KEINER" &&
|
||||
(sudoersVorschau.fehler ? (
|
||||
<p className="text-danger text-[13.5px]">sudoers: {sudoersVorschau.fehler}</p>
|
||||
) : (
|
||||
sudoersVorschau.wert && (
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<p className="text-muted text-[13px] font-medium">
|
||||
<span className="font-mono">/etc/sudoers.d/sshkv-{zielbenutzer.trim()}</span>
|
||||
</p>
|
||||
{/* Ohne Umbruch: sudoers ist zeilenbasiert, ein optischer Umbruch taeuschte eine
|
||||
zweite Regel vor (#019). Der Block scrollt dann fuer sich. */}
|
||||
<MonoBlock wert={sudoersVorschau.wert} beschriftung="sudoers-Inhalt" />
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
</Karte>
|
||||
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||||
{laeuft ? "Speichere …" : bearbeiten ? "Änderungen speichern" : "Vorlage anlegen"}
|
||||
</Button>
|
||||
<Button href="/vorlagen">Abbrechen</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
"use server";
|
||||
|
||||
import { ZielkontoFehler } from "@/lib/ssh/zielkonto";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { akteurVon } from "@/server/actions/formular";
|
||||
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { HostFehler } from "@/server/services/hosts";
|
||||
import { erzeugenAnfordern } from "@/server/services/schluessel";
|
||||
import {
|
||||
VerwendungsFehler,
|
||||
privateVerwendungAnlegen,
|
||||
publicVerwendungAnlegen,
|
||||
} from "@/server/services/verwendungen";
|
||||
import { zielkontoAnfordern } from "@/server/services/zielkonten";
|
||||
|
||||
/**
|
||||
* Schritte des Host-zu-Host-Assistenten (#020). Jeder Schritt ist eine eigene Aktion, die
|
||||
* Oberflaeche verkettet sie und wartet dazwischen auf den Worker. So bleibt jede Aktion
|
||||
* duenn, und jeder Schritt hat seine eigene Rechtepruefung.
|
||||
*/
|
||||
export type SchrittAntwort = {
|
||||
readonly vorgangId?: string;
|
||||
readonly privatVerwendungId?: string;
|
||||
readonly fehler?: string;
|
||||
/** Kritischer Schritt ohne frische Bestaetigung: dorthin, dann weiter. */
|
||||
readonly bestaetigen?: boolean;
|
||||
};
|
||||
|
||||
function alsAntwort(fehler: unknown): SchrittAntwort {
|
||||
if (fehler instanceof BestaetigungFehler) return { bestaetigen: true, fehler: fehler.message };
|
||||
if (
|
||||
fehler instanceof RechteFehler ||
|
||||
fehler instanceof VerwendungsFehler ||
|
||||
fehler instanceof ZielkontoFehler ||
|
||||
fehler instanceof HostFehler
|
||||
) {
|
||||
return { fehler: fehler.message };
|
||||
}
|
||||
throw fehler;
|
||||
}
|
||||
|
||||
const id = z.string().min(1).max(64);
|
||||
|
||||
export async function assistentErzeugenAktion(eingabe: {
|
||||
name: string;
|
||||
vorlageId: string | null;
|
||||
ablaufAm: string | null;
|
||||
}): Promise<SchrittAntwort> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("privateKeyAusrollen");
|
||||
return {
|
||||
vorgangId: await erzeugenAnfordern(
|
||||
{
|
||||
name: eingabe.name,
|
||||
vorlageId: eingabe.vorlageId,
|
||||
ablaufAm: eingabe.ablaufAm,
|
||||
tags: ["host-zu-host"],
|
||||
},
|
||||
undefined,
|
||||
akteurVon(benutzer),
|
||||
),
|
||||
};
|
||||
} catch (fehler) {
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function assistentZielkontoAktion(eingabe: {
|
||||
hostId: string;
|
||||
benutzer: string;
|
||||
modus: "KEINER" | "BEFEHLE" | "VOLL";
|
||||
befehle: string[];
|
||||
}): Promise<SchrittAntwort> {
|
||||
try {
|
||||
// Ein neuer Benutzer mit sudo ist so viel wert wie root: Admin mit frischer Bestaetigung.
|
||||
const { benutzer } = await rechtErforderlich("zielkontenVerwalten");
|
||||
return {
|
||||
vorgangId: await zielkontoAnfordern({ ...eingabe, hostId: id.parse(eingabe.hostId) }, benutzer),
|
||||
};
|
||||
} catch (fehler) {
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function assistentZuweisenAktion(eingabe: {
|
||||
schluesselId: string;
|
||||
quelle: { hostId: string; benutzer: string; pfad: string };
|
||||
ziel: { hostId: string; benutzer: string; optionen: string | null };
|
||||
ssh: { alias: string; hostName: string; port: number; knownHosts: boolean };
|
||||
}): Promise<SchrittAntwort> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("privateKeyAusrollen");
|
||||
const akteur = akteurVon(benutzer);
|
||||
const privatVerwendungId = await privateVerwendungAnlegen(
|
||||
{
|
||||
schluesselId: id.parse(eingabe.schluesselId),
|
||||
hostId: id.parse(eingabe.quelle.hostId),
|
||||
zielbenutzer: eingabe.quelle.benutzer,
|
||||
pfad: eingabe.quelle.pfad,
|
||||
sshConfig: {
|
||||
alias: eingabe.ssh.alias,
|
||||
hostName: eingabe.ssh.hostName,
|
||||
benutzer: eingabe.ziel.benutzer,
|
||||
port: eingabe.ssh.port,
|
||||
},
|
||||
knownHostsFuer: eingabe.ssh.knownHosts ? [id.parse(eingabe.ziel.hostId)] : [],
|
||||
},
|
||||
akteur,
|
||||
);
|
||||
await publicVerwendungAnlegen(
|
||||
{
|
||||
schluesselId: eingabe.schluesselId,
|
||||
hostId: id.parse(eingabe.ziel.hostId),
|
||||
zielbenutzer: eingabe.ziel.benutzer,
|
||||
optionen: eingabe.ziel.optionen,
|
||||
},
|
||||
akteur,
|
||||
);
|
||||
return { privatVerwendungId };
|
||||
} catch (fehler) {
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/** Was der Host-zu-Host-Assistent zur Auswahl braucht (#020) – nur oeffentliche Angaben. */
|
||||
export async function assistentDaten() {
|
||||
const [hosts, vorlagen] = await Promise.all([
|
||||
prisma.host.findMany({
|
||||
where: { archiviertAm: null },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
adresse: true,
|
||||
port: true,
|
||||
bootstrapAm: true,
|
||||
hostSchluessel: { where: { status: "VERTRAUT" }, select: { algorithmus: true, fingerprint: true } },
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
prisma.vorlage.findMany({
|
||||
where: { archiviertAm: null, kategorie: "DIENST" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
beschreibung: true,
|
||||
standardLaufzeitTage: true,
|
||||
zielbenutzer: true,
|
||||
zielbenutzerAnlegen: true,
|
||||
sudoModus: true,
|
||||
sudoBefehle: true,
|
||||
authorizedKeysOptionen: true,
|
||||
sshConfigAlias: true,
|
||||
knownHostsEintragen: true,
|
||||
},
|
||||
orderBy: [{ eingebaut: "desc" }, { name: "asc" }],
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
hosts: hosts
|
||||
.filter((h) => h.bootstrapAm !== null)
|
||||
.map((h) => ({ id: h.id, name: h.name, adresse: h.adresse, port: h.port, hostKeys: h.hostSchluessel })),
|
||||
vorlagen,
|
||||
};
|
||||
}
|
||||
|
||||
export type AssistentDaten = Awaited<ReturnType<typeof assistentDaten>>;
|
||||
@@ -0,0 +1,40 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Lesemodelle fuer Vorlagen (#019). Vorlagen enthalten keine Geheimnisse – jede angemeldete
|
||||
* Rolle darf sie sehen; die Seite blendet nur die Bearbeiten-Knoepfe aus.
|
||||
*/
|
||||
|
||||
const AUSWAHL = {
|
||||
id: true,
|
||||
name: true,
|
||||
beschreibung: true,
|
||||
eingebaut: true,
|
||||
schluesselTyp: true,
|
||||
kategorie: true,
|
||||
standardLaufzeitTage: true,
|
||||
zielbenutzer: true,
|
||||
zielbenutzerAnlegen: true,
|
||||
sudoModus: true,
|
||||
sudoBefehle: true,
|
||||
authorizedKeysOptionen: true,
|
||||
sshConfigAlias: true,
|
||||
knownHostsEintragen: true,
|
||||
privatNachAusrollenLoeschen: true,
|
||||
archiviertAm: true,
|
||||
} as const;
|
||||
|
||||
/** Eingebaute zuerst in Seed-Reihenfolge – sie sind der Normalfall, eigene die Ausnahme. */
|
||||
export async function vorlagenListe() {
|
||||
return prisma.vorlage.findMany({
|
||||
where: { archiviertAm: null },
|
||||
orderBy: [{ eingebaut: "desc" }, { angelegtAm: "asc" }, { name: "asc" }],
|
||||
select: AUSWAHL,
|
||||
});
|
||||
}
|
||||
|
||||
export type VorlagenListenEintrag = Awaited<ReturnType<typeof vorlagenListe>>[number];
|
||||
|
||||
export async function vorlageDetail(id: string) {
|
||||
return prisma.vorlage.findUnique({ where: { id }, select: AUSWAHL });
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { BENUTZERNAME_MUSTER } from "@/lib/shell";
|
||||
import { istGeschuetzt, sudoersInhalt, ZielkontoFehler, type SudoModus } from "@/lib/ssh/zielkonto";
|
||||
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||
|
||||
/**
|
||||
* Zielkonten anfordern (#018). Nur Admins mit frischer Bestaetigung (Recht
|
||||
* zielkontenVerwalten) – ein neuer Benutzer mit sudo ist so viel wert wie root.
|
||||
*/
|
||||
export function zielkontoEingabePruefen(
|
||||
benutzer: string,
|
||||
modus: SudoModus,
|
||||
befehle: readonly string[],
|
||||
): void {
|
||||
if (!BENUTZERNAME_MUSTER.test(benutzer)) throw new ZielkontoFehler("Ungültiger Benutzername.");
|
||||
if (istGeschuetzt(benutzer))
|
||||
throw new ZielkontoFehler(`„${benutzer}“ verwaltet sshkv nicht als Zielkonto.`);
|
||||
sudoersInhalt(benutzer, modus, befehle);
|
||||
}
|
||||
|
||||
export async function zielkontoAnfordern(
|
||||
eingabe: { hostId: string; benutzer: string; modus: SudoModus; befehle: readonly string[] },
|
||||
akteur: { id: string },
|
||||
): Promise<string> {
|
||||
zielkontoEingabePruefen(eingabe.benutzer, eingabe.modus, eingabe.befehle);
|
||||
return vorgangEinreihen({
|
||||
art: "ZIELKONTO_ANLEGEN",
|
||||
angefordertVonId: akteur.id,
|
||||
hostId: eingabe.hostId,
|
||||
eingabe: { benutzer: eingabe.benutzer, modus: eingabe.modus, befehle: [...eingabe.befehle] },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { sudoersDatei, sudoersInhalt, type SudoModus } from "@/lib/ssh/zielkonto";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
import { VorgangsFehler, type Handler } from "@/worker/ausfuehren";
|
||||
import { hostLaden } from "@/worker/ablaeufe/hostkey";
|
||||
import { hostVerbinden } from "@/worker/ssh/host";
|
||||
import { zielkontoAnwenden, zielkontoEntfernen } from "@/worker/ssh/zielkonto";
|
||||
|
||||
/** Vorgaenge ZIELKONTO_ANLEGEN und ZIELKONTO_ENTFERNEN (#018). */
|
||||
type Eingabe = { benutzer?: unknown; modus?: unknown; befehle?: unknown };
|
||||
|
||||
function eingabeLesen(roh: unknown): { benutzer: string; modus: SudoModus; befehle: string[] } {
|
||||
const e = (roh ?? {}) as Eingabe;
|
||||
if (typeof e.benutzer !== "string") throw new VorgangsFehler("Kein Benutzer angegeben.");
|
||||
const modus = (["KEINER", "BEFEHLE", "VOLL"] as const).find((m) => m === e.modus) ?? "KEINER";
|
||||
const befehle = Array.isArray(e.befehle) ? e.befehle.filter((b): b is string => typeof b === "string") : [];
|
||||
return { benutzer: e.benutzer, modus, befehle };
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export const zielkontoAnlegenHandler: Handler = async ({ vorgang }) => {
|
||||
const host = await hostLaden(vorgang.hostId);
|
||||
const konto = eingabeLesen(vorgang.eingabe);
|
||||
const verbindung = await hostVerbinden(host);
|
||||
let angelegt: boolean;
|
||||
try {
|
||||
({ angelegt } = await zielkontoAnwenden(verbindung, host, konto));
|
||||
} finally {
|
||||
verbindung.end();
|
||||
}
|
||||
const inhalt = sudoersInhalt(konto.benutzer, konto.modus, konto.befehle);
|
||||
const vorher = await prisma.zielkonto.findUnique({
|
||||
where: { hostId_benutzername: { hostId: host.id, benutzername: konto.benutzer } },
|
||||
});
|
||||
await prisma.zielkonto.upsert({
|
||||
where: { hostId_benutzername: { hostId: host.id, benutzername: konto.benutzer } },
|
||||
create: {
|
||||
hostId: host.id,
|
||||
benutzername: konto.benutzer,
|
||||
vonSshkvAngelegt: angelegt,
|
||||
sudoModus: konto.modus,
|
||||
sudoBefehle: konto.befehle,
|
||||
sudoersDatei: inhalt ? sudoersDatei(konto.benutzer) : null,
|
||||
sudoersInhalt: inhalt,
|
||||
},
|
||||
update: {
|
||||
// Wer den Benutzer einmal angelegt hat, bleibt dabei – sonst loeschte ein Widerruf ihn nie.
|
||||
vonSshkvAngelegt: (vorher?.vonSshkvAngelegt && !vorher.entferntAm) || angelegt,
|
||||
sudoModus: konto.modus,
|
||||
sudoBefehle: konto.befehle,
|
||||
sudoersDatei: inhalt ? sudoersDatei(konto.benutzer) : null,
|
||||
sudoersInhalt: inhalt,
|
||||
entferntAm: null,
|
||||
},
|
||||
});
|
||||
await protokollieren({
|
||||
akteur: await akteurVon(vorgang.angefordertVonId),
|
||||
aktion: angelegt ? "zielkonto.angelegt" : "zielkonto.geaendert",
|
||||
ergebnis: "ERFOLG",
|
||||
hostName: host.name,
|
||||
zielbenutzer: konto.benutzer,
|
||||
diff: inhalt ?? "(keine sudoers-Datei)",
|
||||
details: { sudoModus: konto.modus },
|
||||
});
|
||||
return { ergebnis: { angelegt, benutzer: konto.benutzer } };
|
||||
};
|
||||
|
||||
export const zielkontoEntfernenHandler: Handler = async ({ vorgang }) => {
|
||||
const host = await hostLaden(vorgang.hostId);
|
||||
const { benutzer } = eingabeLesen(vorgang.eingabe);
|
||||
const konto = await prisma.zielkonto.findUnique({
|
||||
where: { hostId_benutzername: { hostId: host.id, benutzername: benutzer } },
|
||||
});
|
||||
if (!konto || konto.entferntAm) throw new VorgangsFehler("Dieses Zielkonto verwaltet sshkv nicht (mehr).");
|
||||
const verbindung = await hostVerbinden(host);
|
||||
try {
|
||||
// Den Benutzer selbst nur loeschen, wenn sshkv ihn angelegt hat.
|
||||
await zielkontoEntfernen(verbindung, host, benutzer, konto.vonSshkvAngelegt);
|
||||
} finally {
|
||||
verbindung.end();
|
||||
}
|
||||
await prisma.zielkonto.update({ where: { id: konto.id }, data: { entferntAm: new Date() } });
|
||||
await protokollieren({
|
||||
akteur: await akteurVon(vorgang.angefordertVonId),
|
||||
aktion: "zielkonto.entfernt",
|
||||
ergebnis: "ERFOLG",
|
||||
hostName: host.name,
|
||||
zielbenutzer: benutzer,
|
||||
details: { benutzerGeloescht: konto.vonSshkvAngelegt },
|
||||
});
|
||||
return { ergebnis: { benutzerGeloescht: konto.vonSshkvAngelegt } };
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/
|
||||
import { privateKeyAbholenHandler, privateKeyLoeschenHandler } from "@/worker/ablaeufe/privatekey";
|
||||
import { schluesselErzeugenHandler, schluesselImportierenHandler } from "@/worker/ablaeufe/schluessel";
|
||||
import { verbindungstestHandler } from "@/worker/ablaeufe/verbindungstest";
|
||||
import { zielkontoAnlegenHandler, zielkontoEntfernenHandler } from "@/worker/ablaeufe/zielkonto";
|
||||
|
||||
/**
|
||||
* Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Abgleich (#021) …
|
||||
@@ -20,4 +21,6 @@ export const HANDLER: HandlerTabelle = {
|
||||
PRIVATE_KEY_ABHOLEN: privateKeyAbholenHandler,
|
||||
PRIVATE_KEY_LOESCHEN: privateKeyLoeschenHandler,
|
||||
VERBINDUNGSTEST: verbindungstestHandler,
|
||||
ZIELKONTO_ANLEGEN: zielkontoAnlegenHandler,
|
||||
ZIELKONTO_ENTFERNEN: zielkontoEntfernenHandler,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import "dotenv/config";
|
||||
|
||||
/**
|
||||
* Host-zu-Host-Assistent mit der Vorlage „Deployment-Zugang“ (#018–#020), gebauter Stand +
|
||||
* Worker: Benutzer deploy auf B anlegen, Private Key auf A, Public Key auf B, Dry-Run,
|
||||
* ausrollen, Verbindung testen.
|
||||
*/
|
||||
const ROOT_PASSWORT = process.env["SSHKV_TEST_ROOT_PASSWORT"] || "sshkv-test";
|
||||
const PORT = {
|
||||
a: (process.env["SSHKV_TEST_ZIEL_A"] ?? "127.0.0.1:2221").split(":")[1] ?? "2221",
|
||||
b: (process.env["SSHKV_TEST_ZIEL_B"] ?? "127.0.0.1:2222").split(":")[1] ?? "2222",
|
||||
};
|
||||
|
||||
test.setTimeout(240_000);
|
||||
|
||||
function aufZiel(ziel: "ziel-a" | "ziel-b", befehl: string): string {
|
||||
return execFileSync(
|
||||
"docker",
|
||||
["compose", "-f", "docker-compose.test.yml", "exec", "-T", ziel, "sh", "-c", befehl],
|
||||
{
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function hostEinrichten(page: Page, name: string, port: string) {
|
||||
await page.goto("/hosts/neu");
|
||||
await page.getByLabel("Name").fill(name);
|
||||
await page.getByLabel("Adresse").fill("127.0.0.1");
|
||||
await page.getByLabel("Port").fill(port);
|
||||
await page.getByRole("button", { name: "Host anlegen" }).click();
|
||||
await page.getByRole("button", { name: "Host-Key abrufen" }).click();
|
||||
await page.getByRole("button", { name: "Fingerprint stimmt – vertrauen" }).click({ timeout: 30_000 });
|
||||
await page.getByLabel("Passwort", { exact: true }).fill(ROOT_PASSWORT);
|
||||
await page.getByRole("button", { name: "Zugang einrichten" }).click();
|
||||
await expect(page.getByRole("button", { name: "Verbindung prüfen" })).toBeVisible({ timeout: 60_000 });
|
||||
}
|
||||
|
||||
test.beforeAll(() => {
|
||||
const reset =
|
||||
"userdel -r sshkv 2>/dev/null; userdel -r deploy 2>/dev/null; rm -f /etc/sudoers.d/sshkv*; rm -rf /root/.ssh; true";
|
||||
aufZiel("ziel-a", reset);
|
||||
aufZiel("ziel-b", reset);
|
||||
});
|
||||
|
||||
test("Deployment-Zugang mit dem Assistenten", async ({ page }) => {
|
||||
const kennung = Date.now().toString(36);
|
||||
const hostA = `zz-e2e-a-${kennung}`;
|
||||
const hostB = `zz-e2e-b-${kennung}`;
|
||||
|
||||
await page.goto("/anmelden");
|
||||
await page.getByRole("link", { name: /Mit Mock-OIDC anmelden/ }).click();
|
||||
await page.locator('button[data-rolle="admin"]').click();
|
||||
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
|
||||
await hostEinrichten(page, hostA, PORT.a);
|
||||
await hostEinrichten(page, hostB, PORT.b);
|
||||
|
||||
// Frische Bestaetigung vorab (Benutzer anlegen verlangt sie); der Mock braucht auth_time.
|
||||
await page.goto("/bestaetigen?ziel=%2Fassistent");
|
||||
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: "Host-zu-Host-Assistent" })).toBeVisible();
|
||||
|
||||
// 1. Vorlage & Zweck
|
||||
await page.getByLabel("Vorlage").selectOption({ label: "Deployment-Zugang" });
|
||||
await page.getByLabel("Zweck").fill(`zz-e2e Claude Code Deployment ${kennung}`);
|
||||
await expect(page.getByLabel("Ablaufdatum (optional)")).not.toHaveValue("");
|
||||
await page.getByRole("button", { name: "Weiter" }).click();
|
||||
|
||||
// 2. Quelle
|
||||
await page.getByLabel("Host A").selectOption({ label: hostA });
|
||||
await page.getByRole("button", { name: "Weiter" }).click();
|
||||
|
||||
// 3. Ziel
|
||||
await page.getByLabel("Host B").selectOption({ label: hostB });
|
||||
await expect(page.getByLabel("Benutzer auf B")).toHaveValue("deploy");
|
||||
await expect(page.getByLabel("Einschränkungen in authorized_keys")).toHaveValue('from="127.0.0.1"');
|
||||
await expect(page.getByLabel("Alias auf A")).toHaveValue(`deploy-${hostB}`);
|
||||
// Im Docker-Netz kommt A nicht von 127.0.0.1 und erreicht B unter seinem Dienstnamen.
|
||||
await page.getByLabel("Einschränkungen in authorized_keys").fill("no-agent-forwarding");
|
||||
await page.getByLabel("HostName (aus Sicht von A)").fill("ziel-b");
|
||||
await page.getByLabel("Port").fill("22");
|
||||
await page.getByText("Fingerprint von B ist bestätigt").click();
|
||||
await page.screenshot({ path: "docs/anleitung/bilder/assistent-ziel-desktop.png", fullPage: true });
|
||||
await page.getByRole("button", { name: "Weiter" }).click();
|
||||
|
||||
// 4. Pruefen und vorbereiten
|
||||
await expect(page.getByText(`„deploy“ auf ${hostB} anlegen`)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Vorbereiten" }).click();
|
||||
await expect(page.getByRole("button", { name: "Dry-Run starten" })).toBeVisible({ timeout: 90_000 });
|
||||
|
||||
// 5. Dry-Run, ausrollen, testen
|
||||
await page.getByRole("button", { name: "Dry-Run starten" }).click();
|
||||
await expect(page.getByText(`deploy@${hostB}:/home/deploy/.ssh/authorized_keys`)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByRole("button", { name: /Änderung anwenden/ }).click();
|
||||
await expect(page.getByText("Ausgerollt.")).toBeVisible({ timeout: 60_000 });
|
||||
await page.getByRole("button", { name: "Verbindung von A nach B testen" }).click();
|
||||
await expect(page.getByText("Erledigt.")).toBeVisible({ timeout: 30_000 });
|
||||
await page.screenshot({ path: "docs/anleitung/bilder/assistent-fertig-desktop.png", fullPage: true });
|
||||
expect(aufZiel("ziel-a", `ssh -o BatchMode=yes deploy-${hostB} id -un`).trim()).toBe("deploy");
|
||||
|
||||
for (const name of [hostA, hostB]) {
|
||||
await page.goto("/hosts");
|
||||
await page.getByRole("link", { name }).first().click();
|
||||
await page.getByRole("button", { name: "Host archivieren" }).click();
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Archivieren", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/hosts$/);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Vorlagen im gebauten Stand (#019). Testvorlagen heissen zz-e2e-… und werden am Ende des
|
||||
* Ablaufs archiviert, damit die Entwicklungsdatenbank sauber bleibt.
|
||||
*/
|
||||
async function oidcAnmelden(page: Page, rolle: "admin" | "operator" | "reader") {
|
||||
await page.goto("/anmelden");
|
||||
await page.getByRole("link", { name: /Mit Mock-OIDC anmelden/ }).click();
|
||||
await page.locator(`button[data-rolle="${rolle}"]`).click();
|
||||
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
|
||||
}
|
||||
|
||||
test("Admin legt eine Vorlage an, sieht Feldfehler und archiviert sie", async ({ page }) => {
|
||||
const name = `zz-e2e-${Date.now().toString(36)}`;
|
||||
await oidcAnmelden(page, "admin");
|
||||
await page.goto("/vorlagen");
|
||||
await expect(page.getByRole("heading", { name: "Deployment-Zugang" })).toBeVisible();
|
||||
await page.getByRole("link", { name: "Vorlage anlegen" }).click();
|
||||
await expect(page).toHaveURL(/\/vorlagen\/neu$/);
|
||||
|
||||
await page.getByLabel("Name").fill(name);
|
||||
await page.getByLabel("Zielbenutzer").fill("zzdeploy");
|
||||
await page.getByRole("checkbox", { name: /Benutzer anlegen/ }).check();
|
||||
await page.getByLabel("authorized_keys-Optionen (optional)").fill('from="{{quellIp}}",no-pty');
|
||||
|
||||
// Die Vorschau meldet den Fehler schon vor dem Absenden, der Server dann am Feld.
|
||||
await expect(page.getByText("Unbekannter Platzhalter {{quellIp}}.").first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "Vorlage anlegen" }).click();
|
||||
await expect(page.locator("#feld-authorizedKeysOptionen-fehler")).toHaveText(
|
||||
"Unbekannter Platzhalter {{quellIp}}.",
|
||||
);
|
||||
// Eingaben bleiben nach der Feldmeldung stehen.
|
||||
await expect(page.getByLabel("Name")).toHaveValue(name);
|
||||
|
||||
await page.getByLabel("authorized_keys-Optionen (optional)").fill('from="{{quellHostIp}}",no-pty');
|
||||
await page.getByLabel("Alias-Muster für ~/.ssh/config (optional)").fill("zz-{{zielHost}}");
|
||||
await expect(page.getByText('from="10.0.0.5",no-pty')).toBeVisible();
|
||||
await expect(page.getByText("zz-srv-web01")).toBeVisible();
|
||||
|
||||
// sudo „bestimmte Befehle“ blendet das Befehlsfeld ein, „voll“ warnt.
|
||||
await page.getByRole("radio", { name: "voll" }).check();
|
||||
await expect(page.getByRole("note")).toContainText("root");
|
||||
await page.getByRole("radio", { name: "bestimmte Befehle" }).check();
|
||||
await page.getByLabel("Erlaubte Befehle").fill("/usr/bin/systemctl restart nginx");
|
||||
await expect(page.getByLabel("sudoers-Inhalt", { exact: true })).toContainText(
|
||||
"zzdeploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart nginx",
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: "Vorlage anlegen" }).click();
|
||||
await expect(page).toHaveURL(/\/vorlagen$/);
|
||||
const karte = page.locator("article", { has: page.getByRole("heading", { name }) });
|
||||
await expect(karte).toBeVisible();
|
||||
await expect(karte.getByText('from="{{quellHostIp}}",no-pty')).toBeVisible();
|
||||
await expect(karte.getByText("wird angelegt")).toBeVisible();
|
||||
|
||||
await karte.getByRole("link", { name: `${name} bearbeiten` }).click();
|
||||
await page.getByRole("button", { name: "Vorlage archivieren" }).click();
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Archivieren" }).click();
|
||||
await expect(page).toHaveURL(/\/vorlagen$/);
|
||||
await expect(page.getByRole("heading", { name })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("eingebaute Vorlagen haben keinen Archivieren-Knopf", async ({ page }) => {
|
||||
await oidcAnmelden(page, "admin");
|
||||
await page.goto("/vorlagen");
|
||||
await page.getByRole("link", { name: "Deployment-Zugang bearbeiten" }).click();
|
||||
await expect(page.getByLabel("Name")).toHaveValue("Deployment-Zugang");
|
||||
await expect(page.getByRole("button", { name: "Vorlage archivieren" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Leser sieht Vorlagen, darf aber keine anlegen oder bearbeiten", async ({ page }) => {
|
||||
await oidcAnmelden(page, "reader");
|
||||
await page.goto("/vorlagen");
|
||||
await expect(page.getByRole("heading", { name: "Deployment-Zugang" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Vorlage anlegen" })).toHaveCount(0);
|
||||
await expect(page.getByRole("link", { name: /bearbeiten/ })).toHaveCount(0);
|
||||
|
||||
await page.goto("/vorlagen/neu");
|
||||
await expect(page).toHaveURL(/\/kein-zugriff/);
|
||||
});
|
||||
@@ -21,7 +21,14 @@ export function masterKeyAnlegen(): () => void {
|
||||
}
|
||||
|
||||
export async function vorgangLaufen(
|
||||
art: "HOSTKEY_ABRUFEN" | "BOOTSTRAP" | "TROCKENLAUF" | "AUSROLLEN" | "VERBINDUNGSTEST",
|
||||
art:
|
||||
| "HOSTKEY_ABRUFEN"
|
||||
| "BOOTSTRAP"
|
||||
| "TROCKENLAUF"
|
||||
| "AUSROLLEN"
|
||||
| "VERBINDUNGSTEST"
|
||||
| "ZIELKONTO_ANLEGEN"
|
||||
| "ZIELKONTO_ENTFERNEN",
|
||||
daten: {
|
||||
hostId?: string;
|
||||
eingabe?: object;
|
||||
|
||||
@@ -118,3 +118,29 @@ describe("Zielkonto", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Zielkonto als Vorgang", () => {
|
||||
it("legt an, merkt sich die Herkunft und entfernt den Benutzer wieder", async () => {
|
||||
const { prisma } = await import("@/lib/db");
|
||||
const { vorgangLaufen } = await import("./hilfen/host");
|
||||
const host = await eingerichteterHost(zielB, "ziel-b");
|
||||
const anlegen = await vorgangLaufen("ZIELKONTO_ANLEGEN", {
|
||||
hostId: host.id,
|
||||
eingabe: { benutzer: "deploy", modus: "VOLL", befehle: [] },
|
||||
});
|
||||
expect(anlegen.fehler).toBeNull();
|
||||
const konto = await prisma.zielkonto.findFirstOrThrow({ where: { hostId: host.id } });
|
||||
expect(konto).toMatchObject({ benutzername: "deploy", vonSshkvAngelegt: true, sudoModus: "VOLL" });
|
||||
expect(konto.sudoersInhalt).toContain("deploy ALL=(ALL:ALL) NOPASSWD: ALL");
|
||||
|
||||
const entfernen = await vorgangLaufen("ZIELKONTO_ENTFERNEN", {
|
||||
hostId: host.id,
|
||||
eingabe: { benutzer: "deploy" },
|
||||
});
|
||||
expect(entfernen.fehler).toBeNull();
|
||||
expect((await befehlAlsRoot(zielB, "id deploy 2>&1")).stdout).toMatch(/no such user/);
|
||||
expect(
|
||||
(await prisma.zielkonto.findFirstOrThrow({ where: { hostId: host.id } })).entferntAm,
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { VorlagenFehler, type VorlageEingabe } from "@/lib/vorlagen";
|
||||
import { vorlagenListe } from "@/server/queries/vorlagen";
|
||||
import { vorlageAnlegen, vorlageArchivieren, vorlageBearbeiten } from "@/server/services/vorlagen";
|
||||
import { datenbankLeeren } from "./hilfen/db";
|
||||
|
||||
/** Vorlagen-Service (#019) gegen die Testdatenbank: Eindeutigkeit, Protokoll, Schutz der eingebauten. */
|
||||
|
||||
let akteur: { id: string; name: string };
|
||||
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
const benutzer = await prisma.benutzer.create({ data: { name: "Ada Admin", rolle: "ADMIN" } });
|
||||
akteur = { id: benutzer.id, name: benutzer.name };
|
||||
});
|
||||
|
||||
const BASIS: VorlageEingabe = {
|
||||
name: "Monitoring",
|
||||
beschreibung: " Zugang für Prometheus ",
|
||||
schluesselTyp: "ED25519",
|
||||
kategorie: "DIENST",
|
||||
standardLaufzeitTage: 30,
|
||||
zielbenutzer: "monitor",
|
||||
zielbenutzerAnlegen: true,
|
||||
sudoModus: "BEFEHLE",
|
||||
sudoBefehle: ["/usr/bin/systemctl status nginx"],
|
||||
authorizedKeysOptionen: 'from="{{quellHostIp}}",no-pty',
|
||||
sshConfigAlias: "mon-{{zielHost}}",
|
||||
knownHostsEintragen: true,
|
||||
privatNachAusrollenLoeschen: false,
|
||||
};
|
||||
|
||||
async function fehlerVon(versuch: Promise<unknown>): Promise<VorlagenFehler> {
|
||||
const fehler = await versuch.then(
|
||||
() => undefined,
|
||||
(f: unknown) => f,
|
||||
);
|
||||
expect(fehler).toBeInstanceOf(VorlagenFehler);
|
||||
return fehler as VorlagenFehler;
|
||||
}
|
||||
|
||||
describe("Vorlage anlegen", () => {
|
||||
it("speichert bereinigt, laesst Platzhalter stehen und protokolliert", async () => {
|
||||
const id = await vorlageAnlegen(BASIS, akteur);
|
||||
const v = await prisma.vorlage.findUniqueOrThrow({ where: { id } });
|
||||
expect(v.eingebaut).toBe(false);
|
||||
expect(v.beschreibung).toBe("Zugang für Prometheus");
|
||||
expect(v.sudoBefehle).toEqual(["/usr/bin/systemctl status nginx"]);
|
||||
expect(v.authorizedKeysOptionen).toBe('from="{{quellHostIp}}",no-pty');
|
||||
expect(v.sshConfigAlias).toBe("mon-{{zielHost}}");
|
||||
|
||||
const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "vorlage.angelegt" } });
|
||||
expect(eintrag.akteurId).toBe(akteur.id);
|
||||
expect(eintrag.details).toMatchObject({ name: "Monitoring" });
|
||||
expect((await vorlagenListe()).map((l) => l.name)).toEqual(["Monitoring"]);
|
||||
});
|
||||
|
||||
it("verwirft sudo-Befehle, wenn der Modus nicht BEFEHLE ist", async () => {
|
||||
const id = await vorlageAnlegen({ ...BASIS, sudoModus: "KEINER" }, akteur);
|
||||
expect((await prisma.vorlage.findUniqueOrThrow({ where: { id } })).sudoBefehle).toEqual([]);
|
||||
});
|
||||
|
||||
it("meldet einen doppelten Namen am Feld name", async () => {
|
||||
await vorlageAnlegen(BASIS, akteur);
|
||||
const fehler = await fehlerVon(vorlageAnlegen({ ...BASIS, zielbenutzer: "anders" }, akteur));
|
||||
expect(fehler.feld).toBe("name");
|
||||
expect(await prisma.vorlage.count()).toBe(1);
|
||||
});
|
||||
|
||||
it("meldet einen unbekannten Platzhalter am Feld der Optionen", async () => {
|
||||
const fehler = await fehlerVon(
|
||||
vorlageAnlegen({ ...BASIS, authorizedKeysOptionen: 'from="{{quellIp}}"' }, akteur),
|
||||
);
|
||||
expect(fehler.feld).toBe("authorizedKeysOptionen");
|
||||
expect(fehler.message).toContain("{{quellIp}}");
|
||||
expect(await prisma.vorlage.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Vorlage bearbeiten", () => {
|
||||
it("protokolliert genau die geaenderten Felder", async () => {
|
||||
const id = await vorlageAnlegen(BASIS, akteur);
|
||||
await vorlageBearbeiten(
|
||||
id,
|
||||
{ ...BASIS, standardLaufzeitTage: 60, sudoBefehle: ["/usr/bin/systemctl status nginx", "/usr/bin/id"] },
|
||||
akteur,
|
||||
);
|
||||
const v = await prisma.vorlage.findUniqueOrThrow({ where: { id } });
|
||||
expect(v.standardLaufzeitTage).toBe(60);
|
||||
|
||||
const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "vorlage.geaendert" } });
|
||||
const details = eintrag.details as { name: string; geaendert: string[] };
|
||||
expect(details.name).toBe("Monitoring");
|
||||
expect(details.geaendert.sort()).toEqual(["standardLaufzeitTage", "sudoBefehle"]);
|
||||
});
|
||||
|
||||
it("meldet beim Umbenennen auf einen vergebenen Namen das Feld name", async () => {
|
||||
await vorlageAnlegen(BASIS, akteur);
|
||||
const id = await vorlageAnlegen({ ...BASIS, name: "Zweite" }, akteur);
|
||||
const fehler = await fehlerVon(vorlageBearbeiten(id, BASIS, akteur));
|
||||
expect(fehler.feld).toBe("name");
|
||||
});
|
||||
|
||||
it("verweigert archivierte Vorlagen", async () => {
|
||||
const id = await vorlageAnlegen(BASIS, akteur);
|
||||
await vorlageArchivieren(id, akteur);
|
||||
await fehlerVon(vorlageBearbeiten(id, BASIS, akteur));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Vorlage archivieren", () => {
|
||||
it("archiviert eigene Vorlagen und blendet sie aus der Liste aus", async () => {
|
||||
const id = await vorlageAnlegen(BASIS, akteur);
|
||||
await vorlageArchivieren(id, akteur);
|
||||
expect((await prisma.vorlage.findUniqueOrThrow({ where: { id } })).archiviertAm).not.toBeNull();
|
||||
expect(await vorlagenListe()).toEqual([]);
|
||||
expect(await prisma.protokoll.count({ where: { aktion: "vorlage.archiviert" } })).toBe(1);
|
||||
});
|
||||
|
||||
it("laesst eingebaute Vorlagen nicht archivieren, aber bearbeiten", async () => {
|
||||
const eingebaut = await prisma.vorlage.create({
|
||||
data: { name: "Deployment-Zugang", eingebaut: true, zielbenutzer: "deploy" },
|
||||
});
|
||||
await fehlerVon(vorlageArchivieren(eingebaut.id, akteur));
|
||||
expect((await prisma.vorlage.findUniqueOrThrow({ where: { id: eingebaut.id } })).archiviertAm).toBeNull();
|
||||
|
||||
await vorlageBearbeiten(eingebaut.id, { ...BASIS, name: "Deployment-Zugang" }, akteur);
|
||||
const v = await prisma.vorlage.findUniqueOrThrow({ where: { id: eingebaut.id } });
|
||||
expect(v.eingebaut).toBe(true);
|
||||
expect(v.zielbenutzer).toBe("monitor");
|
||||
});
|
||||
});
|
||||