Merge branch 'phase/016-017-schluesselseite'
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 148 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 124 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 209 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 214 KiB |
@@ -1,10 +1,35 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Platzhalter fuer „+ Ausrollen“ in der Karte „Private Key ausgerollt auf“ (#012).
|
||||
* Inhalt folgt mit #017 (Private Key auf einen Host legen); bis dahin bewusst leer.
|
||||
*/
|
||||
export function PrivateAusrollenAktion(props: { schluesselId: string; darf: boolean }): null {
|
||||
void props;
|
||||
return null;
|
||||
import { AusrollDialog } from "@/components/ausrollen/AusrollDialog";
|
||||
import { PrivateZuweisenFormular } from "@/components/ausrollen/PrivateZuweisenFormular";
|
||||
import type { ZielAuswahl } from "@/server/queries/ziele";
|
||||
|
||||
/** „+ Ausrollen“ in der Karte „Private Key ausgerollt auf“ (#017) – nur Admins. */
|
||||
export function PrivateAusrollenAktion({
|
||||
schluesselId,
|
||||
darf,
|
||||
auswahl,
|
||||
slug,
|
||||
passphraseGeschuetzt = false,
|
||||
}: {
|
||||
schluesselId: string;
|
||||
darf: boolean;
|
||||
auswahl?: ZielAuswahl;
|
||||
slug?: string;
|
||||
passphraseGeschuetzt?: boolean;
|
||||
}) {
|
||||
if (!darf || !auswahl || !slug) return null;
|
||||
return (
|
||||
<AusrollDialog titel="Private Key ausrollen">
|
||||
{(schliessen) => (
|
||||
<PrivateZuweisenFormular
|
||||
schluesselId={schluesselId}
|
||||
slug={slug}
|
||||
auswahl={auswahl}
|
||||
passphraseGeschuetzt={passphraseGeschuetzt}
|
||||
beiErfolg={schliessen}
|
||||
/>
|
||||
)}
|
||||
</AusrollDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Platzhalter fuer „+ Ausrollen“ in der Karte „Public Key ausgerollt auf“ (#012).
|
||||
* Inhalt folgt mit #016 (Zuweisung, Dry-Run, Ausrollen); bis dahin bewusst leer.
|
||||
*/
|
||||
export function PublicAusrollenAktion(props: { schluesselId: string; darf: boolean }): null {
|
||||
void props;
|
||||
return null;
|
||||
import { AusrollDialog } from "@/components/ausrollen/AusrollDialog";
|
||||
import { PublicZuweisenFormular } from "@/components/ausrollen/PublicZuweisenFormular";
|
||||
import type { ZielAuswahl } from "@/server/queries/ziele";
|
||||
|
||||
/** „+ Ausrollen“ in der Karte „Public Key ausgerollt auf“ (#016). */
|
||||
export function PublicAusrollenAktion({
|
||||
schluesselId,
|
||||
darf,
|
||||
auswahl,
|
||||
}: {
|
||||
schluesselId: string;
|
||||
darf: boolean;
|
||||
auswahl?: ZielAuswahl;
|
||||
}) {
|
||||
if (!darf || !auswahl) return null;
|
||||
return (
|
||||
<AusrollDialog titel="Public Key zuweisen">
|
||||
{(schliessen) => (
|
||||
<PublicZuweisenFormular schluesselId={schluesselId} auswahl={auswahl} beiErfolg={schliessen} />
|
||||
)}
|
||||
</AusrollDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ import { datum, datumZeit } from "@/lib/format";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { schluesselDetail, type SchluesselDetail } from "@/server/queries/schluessel";
|
||||
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
||||
import { EintragAktionen } from "@/components/ausrollen/EintragAktionen";
|
||||
import { zielAuswahl } from "@/server/queries/ziele";
|
||||
import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion";
|
||||
import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen";
|
||||
import { PublicAusrollenAktion } from "./PublicAusrollenAktion";
|
||||
@@ -40,7 +43,15 @@ const ZUSTAND: Record<string, { status: HostStatus; text: string }> = {
|
||||
FEHLGESCHLAGEN: { status: "drift", text: "fehlgeschlagen" },
|
||||
};
|
||||
|
||||
type Eintrag = { id: string; ziel: string; href?: string; pfad: string; zusatz?: string; zustand: string };
|
||||
type Eintrag = {
|
||||
id: string;
|
||||
ziel: string;
|
||||
href?: string;
|
||||
pfad: string;
|
||||
zusatz?: string;
|
||||
zustand: string;
|
||||
aktionen?: ReactNode;
|
||||
};
|
||||
|
||||
function AusrollKarte({
|
||||
titel,
|
||||
@@ -67,7 +78,8 @@ function AusrollKarte({
|
||||
const z = ZUSTAND[e.zustand] ?? { status: "unbekannt" as const, text: e.zustand };
|
||||
return (
|
||||
<li key={e.id}>
|
||||
<InnereBox className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<InnereBox className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-text font-mono text-[13.5px] font-medium break-all">
|
||||
{e.href ? (
|
||||
@@ -82,6 +94,9 @@ function AusrollKarte({
|
||||
{e.zusatz && <p className="text-muted font-mono text-[12.5px] break-all">{e.zusatz}</p>}
|
||||
</div>
|
||||
<Statuspunkt status={z.status} text={z.text} className="shrink-0 sm:pt-0.5" />
|
||||
</div>
|
||||
{/* Aktionen unter dem Eintrag, damit lange Hostnamen nicht umbrechen (#016). */}
|
||||
{e.aktionen}
|
||||
</InnereBox>
|
||||
</li>
|
||||
);
|
||||
@@ -92,7 +107,7 @@ function AusrollKarte({
|
||||
);
|
||||
}
|
||||
|
||||
function privateEintraege(s: SchluesselDetail): Eintrag[] {
|
||||
function privateEintraege(s: SchluesselDetail, rechte: Rechte): Eintrag[] {
|
||||
return s.privateVerwendungen.map((v) => {
|
||||
const config =
|
||||
v.sshConfig && typeof v.sshConfig === "object" ? (v.sshConfig as { alias?: unknown }) : null;
|
||||
@@ -103,11 +118,22 @@ function privateEintraege(s: SchluesselDetail): Eintrag[] {
|
||||
pfad: v.pfad,
|
||||
...(typeof config?.alias === "string" ? { zusatz: `config: Host ${config.alias}` } : {}),
|
||||
zustand: v.zustand,
|
||||
aktionen: (
|
||||
<EintragAktionen
|
||||
art="privat"
|
||||
verwendungId={v.id}
|
||||
schluesselId={s.id}
|
||||
darfEntfernen={rechte.privat && v.zustand !== "ENTFERNEN"}
|
||||
testbar={rechte.testen && v.zustand === "AUSGEROLLT" && typeof config?.alias === "string"}
|
||||
/>
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function publicEintraege(s: SchluesselDetail): Eintrag[] {
|
||||
type Rechte = { readonly public: boolean; readonly privat: boolean; readonly testen: boolean };
|
||||
|
||||
function publicEintraege(s: SchluesselDetail, rechte: Rechte): Eintrag[] {
|
||||
return s.publicVerwendungen.map((v) => ({
|
||||
id: v.id,
|
||||
ziel: v.host
|
||||
@@ -121,6 +147,15 @@ function publicEintraege(s: SchluesselDetail): Eintrag[] {
|
||||
pfad: "~/.ssh/authorized_keys",
|
||||
...(v.optionen ? { zusatz: v.optionen } : {}),
|
||||
zustand: v.zustand,
|
||||
aktionen: (
|
||||
<EintragAktionen
|
||||
art="public"
|
||||
verwendungId={v.id}
|
||||
schluesselId={s.id}
|
||||
darfEntfernen={rechte.public && v.zustand !== "ENTFERNEN"}
|
||||
testbar={false}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -159,6 +194,12 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
|
||||
if (!s) notFound();
|
||||
|
||||
const aktiv = s.status === "AKTIV";
|
||||
const rechte: Rechte = {
|
||||
public: darf(benutzer.rolle, "publicKeyAusrollen"),
|
||||
privat: darf(benutzer.rolle, "privateKeyAusrollen"),
|
||||
testen: darf(benutzer.rolle, "hostsVerwalten"),
|
||||
};
|
||||
const auswahl = aktiv && rechte.public ? await zielAuswahl() : undefined;
|
||||
const personenKey = s.kategorie === "PERSON";
|
||||
const verwalten = darf(benutzer.rolle, "schluesselVerwalten");
|
||||
// Den Deployment-Key gibt sshkv nie heraus (#014) – Knoepfe dafuer erscheinen gar nicht erst.
|
||||
@@ -243,7 +284,7 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
|
||||
<AusrollKarte
|
||||
titel="Private Key ausgerollt auf"
|
||||
untertitel="Hosts, die sich mit diesem Schlüssel anmelden"
|
||||
eintraege={privateEintraege(s)}
|
||||
eintraege={privateEintraege(s, rechte)}
|
||||
leer={
|
||||
s.privatGespeichert
|
||||
? "Der Private Key liegt noch auf keinem Host."
|
||||
@@ -252,7 +293,10 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
|
||||
aktion={
|
||||
<PrivateAusrollenAktion
|
||||
schluesselId={s.id}
|
||||
darf={aktiv && s.privatGespeichert && darf(benutzer.rolle, "privateKeyAusrollen")}
|
||||
darf={aktiv && s.privatGespeichert && rechte.privat}
|
||||
{...(auswahl ? { auswahl } : {})}
|
||||
slug={s.slug}
|
||||
passphraseGeschuetzt={s.passphraseGeschuetzt}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -260,7 +304,7 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
|
||||
<AusrollKarte
|
||||
titel="Public Key ausgerollt auf"
|
||||
untertitel="Konten, die diesen Schlüssel annehmen"
|
||||
eintraege={publicEintraege(s)}
|
||||
eintraege={publicEintraege(s, rechte)}
|
||||
leer={
|
||||
personenKey
|
||||
? "Noch keinem Konto zugewiesen – wirksam erst, wenn ein Operator oder Admin den Schlüssel einem Host zuweist."
|
||||
@@ -269,12 +313,29 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
|
||||
aktion={
|
||||
<PublicAusrollenAktion
|
||||
schluesselId={s.id}
|
||||
darf={aktiv && darf(benutzer.rolle, "publicKeyAusrollen")}
|
||||
darf={aktiv && rechte.public}
|
||||
{...(auswahl ? { auswahl } : {})}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{rechte.public &&
|
||||
(s.publicVerwendungen.length > 0 || (rechte.privat && s.privateVerwendungen.length > 0)) && (
|
||||
<Karte
|
||||
titel="Dry-Run-Vorschau"
|
||||
untertitel="Erst prüfen, dann anwenden – auf den Hosts ändert sich vorher nichts"
|
||||
>
|
||||
<AusrollenPanel
|
||||
auswahl={{ schluesselId: s.id }}
|
||||
offen={
|
||||
s.publicVerwendungen.filter((v) => v.zustand !== "AUSGEROLLT").length +
|
||||
(rechte.privat ? s.privateVerwendungen.filter((v) => v.zustand !== "AUSGEROLLT").length : 0)
|
||||
}
|
||||
/>
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
<Karte
|
||||
titel="Public Key"
|
||||
untertitel={
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
/**
|
||||
* „+ Ausrollen“: Knopf mit nativem Dialog (#016, #017). Offen/zu als Zustand, der Effekt
|
||||
* gleicht den Dialog an – so liest kein Render-Pfad die Ref.
|
||||
*/
|
||||
export function AusrollDialog({
|
||||
titel,
|
||||
children,
|
||||
}: {
|
||||
titel: string;
|
||||
children: (schliessen: () => void) => ReactNode;
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const [offen, setOffen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const element = dialog.current;
|
||||
if (!element) return;
|
||||
if (offen && !element.open) element.showModal();
|
||||
if (!offen && element.open) element.close();
|
||||
}, [offen]);
|
||||
|
||||
const schliessen = () => setOffen(false);
|
||||
return (
|
||||
<>
|
||||
<Button klein icon={<Plus className="size-4" />} onClick={() => setOffen(true)}>
|
||||
Ausrollen
|
||||
</Button>
|
||||
<dialog
|
||||
ref={dialog}
|
||||
aria-label={titel}
|
||||
onClose={schliessen}
|
||||
className="border-border-strong bg-surface text-text rounded-karte m-auto w-[calc(100%-32px)] max-w-lg border p-5 backdrop:bg-black/70 sm:p-6"
|
||||
>
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<h2 className="font-display text-[18px] font-semibold">{titel}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={schliessen}
|
||||
aria-label="Schließen"
|
||||
className="text-muted hover:text-text hover:bg-raised rounded-feld -mt-2 -mr-2 flex size-11 items-center justify-center"
|
||||
>
|
||||
<X className="size-5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
{offen && children(schliessen)}
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,17 @@ import type { DiffZeile } from "@/lib/ssh/zeilen-diff";
|
||||
* schreibt: der Plan traegt die Hashes, und das Ausrollen bricht ab, wenn sich seitdem auf
|
||||
* dem Host oder an den Zuweisungen etwas geaendert hat.
|
||||
*/
|
||||
type DateiPlan = {
|
||||
art: "privateKey" | "publicKey" | "config" | "knownHosts";
|
||||
pfad: string;
|
||||
aktion: "anlegen" | "aendern" | "keine" | "entfernen";
|
||||
diff?: DiffZeile[];
|
||||
hinweis?: string;
|
||||
};
|
||||
|
||||
type PlanZiel = {
|
||||
art?: "public" | "privat";
|
||||
dateien?: DateiPlan[];
|
||||
hostName: string;
|
||||
zielbenutzer: string;
|
||||
pfad?: string;
|
||||
@@ -77,6 +87,8 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of
|
||||
<p className="border-danger-border bg-danger-soft text-danger rounded-box border px-4 py-3 font-mono text-[13px]">
|
||||
{ziel.zielbenutzer}@{ziel.hostName}: {ziel.fehler}
|
||||
</p>
|
||||
) : ziel.art === "privat" ? (
|
||||
<PrivatPlanAnzeige ziel={ziel} />
|
||||
) : (
|
||||
<DiffBox
|
||||
datei={`${ziel.zielbenutzer}@${ziel.hostName}:${ziel.pfad ?? "~/.ssh/authorized_keys"}`}
|
||||
@@ -95,8 +107,9 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of
|
||||
<div className="border-border flex flex-col gap-3 border-t pt-4">
|
||||
<label className="text-muted flex min-h-11 items-center gap-3 text-[14px]">
|
||||
<input type="checkbox" checked disabled className="accent-accent size-5" />
|
||||
Backup jeder Datei anlegen (
|
||||
<code className="font-mono text-[12.5px]">authorized_keys.sshkv.bak.…</code>)
|
||||
Backup jeder geänderten Datei anlegen (
|
||||
<code className="font-mono text-[12.5px]">….sshkv.bak.…</code>) – außer bei Private Keys, von
|
||||
denen keine zweite Kopie liegen bleiben soll
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
@@ -131,3 +144,49 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const AKTION_TEXT: Record<DateiPlan["aktion"], string> = {
|
||||
anlegen: "wird angelegt",
|
||||
aendern: "wird geändert",
|
||||
entfernen: "wird entfernt",
|
||||
keine: "unverändert",
|
||||
};
|
||||
|
||||
/**
|
||||
* Private-Key-Ziel (#017): die Dateien unter ~/.ssh einzeln. Vom Private Key steht nur die
|
||||
* Aktion da – sein Inhalt erreicht den Browser nie.
|
||||
*/
|
||||
function PrivatPlanAnzeige({ ziel }: { ziel: PlanZiel }) {
|
||||
const dateien = ziel.dateien ?? [];
|
||||
return (
|
||||
<div className="border-border bg-bg rounded-box flex flex-col gap-3 border p-4">
|
||||
<p className="text-muted font-mono text-[12.5px] break-all">
|
||||
{ziel.zielbenutzer}@{ziel.hostName} · Private Key, ~/.ssh/config, known_hosts
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1 font-mono text-[12.5px]">
|
||||
{dateien
|
||||
.filter((d) => d.art === "privateKey" || d.art === "publicKey")
|
||||
.map((d) => (
|
||||
<li
|
||||
key={d.pfad}
|
||||
className={
|
||||
d.aktion === "keine" ? "text-muted" : d.aktion === "entfernen" ? "text-danger" : "text-ok"
|
||||
}
|
||||
>
|
||||
{d.aktion === "keine" ? " " : d.aktion === "entfernen" ? "−" : "+"} {d.pfad} –{" "}
|
||||
{AKTION_TEXT[d.aktion]}
|
||||
{d.art === "privateKey" && d.aktion !== "keine" && d.aktion !== "entfernen"
|
||||
? " (Inhalt wird nicht angezeigt)"
|
||||
: ""}
|
||||
{d.hinweis ? ` · ${d.hinweis}` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{dateien
|
||||
.filter((d) => (d.art === "config" || d.art === "knownHosts") && d.aktion !== "keine" && d.diff)
|
||||
.map((d) => (
|
||||
<DiffBox key={d.pfad} datei={`${ziel.zielbenutzer}@${ziel.hostName}:${d.pfad}`} zeilen={d.diff!} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
|
||||
import {
|
||||
privateEntfernenAktion,
|
||||
publicEntfernenAktion,
|
||||
verbindungTestenAktion,
|
||||
} from "@/server/actions/verwendungen";
|
||||
|
||||
/** Je Eintrag: Entfernen vormerken, bei Private Keys mit Alias den Verbindungstest (#016/#017/#020). */
|
||||
export function EintragAktionen({
|
||||
art,
|
||||
verwendungId,
|
||||
schluesselId,
|
||||
darfEntfernen,
|
||||
testbar,
|
||||
}: {
|
||||
art: "public" | "privat";
|
||||
verwendungId: string;
|
||||
schluesselId: string;
|
||||
darfEntfernen: boolean;
|
||||
testbar: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [fehler, setFehler] = useState<string>();
|
||||
const [laeuft, starten] = useTransition();
|
||||
return (
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
{testbar && (
|
||||
<VorgangsKnopf
|
||||
klein
|
||||
aktion={verbindungTestenAktion.bind(null, verwendungId)}
|
||||
beschriftung="Verbindung testen"
|
||||
/>
|
||||
)}
|
||||
{darfEntfernen && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={laeuft}
|
||||
title="Zuweisung entfernen"
|
||||
aria-label="Zuweisung entfernen"
|
||||
onClick={() =>
|
||||
starten(async () => {
|
||||
const antwort = await (art === "public" ? publicEntfernenAktion : privateEntfernenAktion)(
|
||||
verwendungId,
|
||||
schluesselId,
|
||||
);
|
||||
if (antwort.fehler) setFehler(antwort.fehler);
|
||||
else router.refresh();
|
||||
})
|
||||
}
|
||||
className="text-muted hover:text-danger hover:bg-danger-soft rounded-feld flex size-11 items-center justify-center"
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
{fehler && <p className="text-danger text-[13px]">{fehler}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Ankreuzfeld, Button, Feld, Formularfehler } from "@/components/ui";
|
||||
import { Auswahlliste } from "@/components/ui/Feld";
|
||||
import { useFormularAktion } from "@/components/hosts/useFormularAktion";
|
||||
import { privateZuweisenAktion } from "@/server/actions/verwendungen";
|
||||
import type { ZielAuswahl } from "@/server/queries/ziele";
|
||||
|
||||
/**
|
||||
* Private Key auf einen Quell-Host legen (#017), optional mit ~/.ssh/config-Eintrag und dem
|
||||
* Host-Key des Ziels in known_hosts – das, was ein Cronjob auf dem Quell-Host braucht.
|
||||
*/
|
||||
export function PrivateZuweisenFormular({
|
||||
schluesselId,
|
||||
slug,
|
||||
auswahl,
|
||||
passphraseGeschuetzt,
|
||||
beiErfolg,
|
||||
}: {
|
||||
schluesselId: string;
|
||||
slug: string;
|
||||
auswahl: ZielAuswahl & { hostsDetails: { id: string; name: string; adresse: string; port: number }[] };
|
||||
passphraseGeschuetzt: boolean;
|
||||
beiErfolg?: () => void;
|
||||
}) {
|
||||
const [mitConfig, setMitConfig] = useState(true);
|
||||
const [zielHostId, setZielHostId] = useState("");
|
||||
const ziel = auswahl.hostsDetails.find((h) => h.id === zielHostId);
|
||||
const { zustand, absenden, laeuft } = useFormularAktion(async (vorher, formular) => {
|
||||
const ergebnis = await privateZuweisenAktion(vorher, formular);
|
||||
if (!ergebnis.fehler && !ergebnis.feldfehler) beiErfolg?.();
|
||||
return ergebnis;
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={absenden} className="flex flex-col gap-4">
|
||||
<input type="hidden" name="schluesselId" value={schluesselId} />
|
||||
<Formularfehler text={zustand.fehler} />
|
||||
{passphraseGeschuetzt && (
|
||||
<p className="border-border-strong text-accent rounded-box border px-3 py-2.5 text-[13.5px]">
|
||||
▲ Dieser Private Key ist passphrasengeschützt – für automatische Anmeldungen (Host-zu-Host)
|
||||
ungeeignet.
|
||||
</p>
|
||||
)}
|
||||
<Auswahlliste
|
||||
beschriftung="Quell-Host (bekommt den Private Key)"
|
||||
name="hostId"
|
||||
defaultValue=""
|
||||
required
|
||||
>
|
||||
<option value="" disabled>
|
||||
Host wählen …
|
||||
</option>
|
||||
{auswahl.hosts.map((h) => (
|
||||
<option key={h.id} value={h.id}>
|
||||
{h.name}
|
||||
{h.bereit ? "" : " (noch kein Bootstrap)"}
|
||||
</option>
|
||||
))}
|
||||
</Auswahlliste>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld
|
||||
beschriftung="Zielbenutzer"
|
||||
name="zielbenutzer"
|
||||
defaultValue="root"
|
||||
mono
|
||||
required
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Pfad"
|
||||
name="pfad"
|
||||
defaultValue={`~/.ssh/id_ed25519_${slug}`}
|
||||
mono
|
||||
required
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Ankreuzfeld
|
||||
beschriftung="Eintrag in ~/.ssh/config anlegen"
|
||||
erklaerung="Dann genügt auf dem Quell-Host ein einfaches „ssh <Alias>“."
|
||||
checked={mitConfig}
|
||||
onChange={(e) => setMitConfig(e.target.checked)}
|
||||
/>
|
||||
{mitConfig && (
|
||||
<div className="border-border flex flex-col gap-4 border-l-2 pl-4">
|
||||
<Auswahlliste
|
||||
beschriftung="Ziel-Host (für known_hosts)"
|
||||
name="zielHostId"
|
||||
value={zielHostId}
|
||||
onChange={(e) => setZielHostId(e.target.value)}
|
||||
>
|
||||
<option value="">– kein verwalteter Host –</option>
|
||||
{auswahl.hostsDetails.map((h) => (
|
||||
<option key={h.id} value={h.id}>
|
||||
{h.name}
|
||||
</option>
|
||||
))}
|
||||
</Auswahlliste>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld
|
||||
key={`alias-${zielHostId}`}
|
||||
beschriftung="Alias"
|
||||
name="alias"
|
||||
defaultValue={ziel ? ziel.name : ""}
|
||||
mono
|
||||
required
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Feld
|
||||
key={`hn-${zielHostId}`}
|
||||
beschriftung="HostName"
|
||||
name="hostName"
|
||||
defaultValue={ziel?.adresse ?? ""}
|
||||
mono
|
||||
required
|
||||
autoComplete="off"
|
||||
hinweis="So, wie der Quell-Host das Ziel erreicht."
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Benutzer am Ziel"
|
||||
name="sshBenutzer"
|
||||
defaultValue="root"
|
||||
mono
|
||||
required
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Feld
|
||||
key={`port-${zielHostId}`}
|
||||
beschriftung="Port"
|
||||
name="port"
|
||||
type="number"
|
||||
defaultValue={ziel?.port ?? 22}
|
||||
mono
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Auswahlliste beschriftung="Beim Widerruf" name="beimWiderruf" defaultValue="LOESCHEN">
|
||||
<option value="LOESCHEN">Datei löschen</option>
|
||||
<option value="UMBENENNEN">Datei umbenennen (…sshkv-widerrufen…)</option>
|
||||
</Auswahlliste>
|
||||
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||||
{laeuft ? "Speichere …" : "Zuweisen"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -4,13 +4,17 @@ import { revalidatePath } from "next/cache";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { akteurVon, textfeld, type FormularZustand } from "@/server/actions/formular";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import {
|
||||
VerwendungsFehler,
|
||||
ausrollenStarten,
|
||||
privateVerwendungAnlegen,
|
||||
privateVerwendungEntfernen,
|
||||
publicVerwendungAnlegen,
|
||||
publicVerwendungEntfernen,
|
||||
trockenlaufStarten,
|
||||
} from "@/server/services/verwendungen";
|
||||
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||
|
||||
/** Public-Key-Zuweisungen und Ausrollen (#016). Duenn: Recht, Eingabe, Service. */
|
||||
|
||||
@@ -82,6 +86,8 @@ export async function trockenlaufAktion(auswahl: AusrollAuswahl): Promise<Vorgan
|
||||
? { schluesselId: id.parse(auswahl.schluesselId) }
|
||||
: { hostId: id.parse(auswahl.hostId) },
|
||||
akteurVon(benutzer),
|
||||
// Private-Key-Ziele nur fuer Admins – ein Operator rollt so nie einen Private Key aus.
|
||||
darf(benutzer.rolle, "privateKeyAusrollen"),
|
||||
),
|
||||
};
|
||||
} catch (fehler) {
|
||||
@@ -99,3 +105,69 @@ export async function ausrollenAktion(trockenlaufId: string): Promise<VorgangsAn
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
/** Private Key einem Quell-Host zuweisen (#017) – nur Admins. */
|
||||
export async function privateZuweisenAktion(
|
||||
_vorher: FormularZustand,
|
||||
formular: FormData,
|
||||
): Promise<FormularZustand> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("privateKeyAusrollen");
|
||||
const schluesselId = z.string().min(1).max(64).parse(textfeld(formular, "schluesselId"));
|
||||
const alias = (textfeld(formular, "alias") ?? "").trim();
|
||||
const zielHostId = textfeld(formular, "zielHostId");
|
||||
await privateVerwendungAnlegen(
|
||||
{
|
||||
schluesselId,
|
||||
hostId: textfeld(formular, "hostId") ?? "",
|
||||
zielbenutzer: (textfeld(formular, "zielbenutzer") ?? "").trim(),
|
||||
pfad: textfeld(formular, "pfad") ?? "",
|
||||
sshConfig: alias
|
||||
? {
|
||||
alias,
|
||||
hostName: (textfeld(formular, "hostName") ?? "").trim(),
|
||||
benutzer: (textfeld(formular, "sshBenutzer") ?? "").trim(),
|
||||
port: Number(textfeld(formular, "port") ?? "22"),
|
||||
}
|
||||
: null,
|
||||
knownHostsFuer: zielHostId ? [zielHostId] : [],
|
||||
beimWiderruf: textfeld(formular, "beimWiderruf") === "UMBENENNEN" ? "UMBENENNEN" : "LOESCHEN",
|
||||
},
|
||||
akteurVon(benutzer),
|
||||
);
|
||||
revalidatePath(`/schluessel/${schluesselId}`);
|
||||
return {};
|
||||
} catch (fehler) {
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function privateEntfernenAktion(
|
||||
verwendungId: string,
|
||||
schluesselId: string,
|
||||
): Promise<{ fehler?: string }> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("privateKeyAusrollen");
|
||||
await privateVerwendungEntfernen(z.string().min(1).max(64).parse(verwendungId), akteurVon(benutzer));
|
||||
revalidatePath(`/schluessel/${schluesselId}`);
|
||||
return {};
|
||||
} catch (fehler) {
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
/** Verbindungstest A → B (#020). Harmlos, aber nur fuer Rollen, die Hosts verwalten. */
|
||||
export async function verbindungTestenAktion(privatVerwendungId: string): Promise<VorgangsAntwort> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("hostsVerwalten");
|
||||
return {
|
||||
vorgangId: await vorgangEinreihen({
|
||||
art: "VERBINDUNGSTEST",
|
||||
angefordertVonId: benutzer.id,
|
||||
eingabe: { privatVerwendungId: z.string().min(1).max(64).parse(privatVerwendungId) },
|
||||
}),
|
||||
};
|
||||
} catch (fehler) {
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export async function zielAuswahl() {
|
||||
const [hosts, gruppen] = await Promise.all([
|
||||
prisma.host.findMany({
|
||||
where: { archiviertAm: null },
|
||||
select: { id: true, name: true, bootstrapAm: true },
|
||||
select: { id: true, name: true, bootstrapAm: true, adresse: true, port: true },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
prisma.hostgruppe.findMany({
|
||||
@@ -15,6 +15,7 @@ export async function zielAuswahl() {
|
||||
]);
|
||||
return {
|
||||
hosts: hosts.map((h) => ({ id: h.id, name: h.name, bereit: h.bootstrapAm !== null })),
|
||||
hostsDetails: hosts.map((h) => ({ id: h.id, name: h.name, adresse: h.adresse, port: h.port })),
|
||||
gruppen: gruppen.map((g) => ({ id: g.id, name: g.name, mitglieder: g._count.mitglieder })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -120,6 +120,8 @@ export async function publicVerwendungEntfernen(id: string, akteur: Akteur): Pro
|
||||
export async function trockenlaufStarten(
|
||||
auswahl: { schluesselId?: string; hostId?: string; verwendungIds?: string[]; ziele?: Ziel[] },
|
||||
akteur: Akteur,
|
||||
/** Private-Key-Ziele nur fuer Admins (Freigabe Frage 3) – der Aufrufer entscheidet per darf(). */
|
||||
mitPrivat = false,
|
||||
): Promise<string> {
|
||||
const ziele = auswahl.ziele ?? [
|
||||
...(await zieleVonVerwendungen({
|
||||
@@ -128,7 +130,7 @@ export async function trockenlaufStarten(
|
||||
...(auswahl.verwendungIds ? { ids: auswahl.verwendungIds } : {}),
|
||||
})),
|
||||
// Private Keys nur, wenn nicht gezielt einzelne Public-Verwendungen gemeint sind.
|
||||
...(auswahl.verwendungIds
|
||||
...(auswahl.verwendungIds || !mitPrivat
|
||||
? []
|
||||
: await zieleVonPrivatVerwendungen({
|
||||
...(auswahl.schluesselId ? { schluesselId: auswahl.schluesselId } : {}),
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import "dotenv/config";
|
||||
|
||||
/**
|
||||
* Kern-Anwendungsfall im Browser (#012, #016, #017, #020), gebauter Stand + Worker:
|
||||
* Schluessel erzeugen → Private Key auf Ziel A (mit ~/.ssh/config und known_hosts) →
|
||||
* Public Key auf Ziel B → Dry-Run → anwenden → Verbindung von A nach B testen.
|
||||
* Beide Hosts heissen zz-e2e-… und werden am Ende archiviert.
|
||||
*/
|
||||
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): Promise<void> {
|
||||
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 });
|
||||
}
|
||||
|
||||
async function archivieren(page: Page, name: string): Promise<void> {
|
||||
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$/);
|
||||
}
|
||||
|
||||
test.beforeAll(() => {
|
||||
const reset = "userdel -r sshkv 2>/dev/null; rm -f /etc/sudoers.d/sshkv; rm -rf /root/.ssh; true";
|
||||
aufZiel("ziel-a", reset);
|
||||
aufZiel("ziel-b", reset);
|
||||
});
|
||||
|
||||
test("Backup A → B: erzeugen, ausrollen, Verbindung testen", async ({ page }) => {
|
||||
const kennung = Date.now().toString(36);
|
||||
const hostA = `zz-e2e-a-${kennung}`;
|
||||
const hostB = `zz-e2e-b-${kennung}`;
|
||||
const zweck = `zz-e2e Backup ${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 expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
|
||||
|
||||
await hostEinrichten(page, hostA, PORT.a);
|
||||
await hostEinrichten(page, hostB, PORT.b);
|
||||
|
||||
// 1. Schluessel erzeugen
|
||||
await page.goto("/schluessel/neu");
|
||||
await page.getByLabel("Zweck").fill(zweck);
|
||||
await page.getByRole("button", { name: "Schlüssel erzeugen" }).click();
|
||||
await expect(page.getByRole("heading", { name: zweck })).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// 2a. Private Key auf A, mit config-Eintrag und Host-Key von B
|
||||
await page.getByRole("button", { name: "Ausrollen" }).first().click();
|
||||
const privat = page.getByRole("dialog", { name: "Private Key ausrollen" });
|
||||
await privat.getByLabel("Quell-Host (bekommt den Private Key)").selectOption({ label: hostA });
|
||||
await privat.getByLabel("Ziel-Host (für known_hosts)").selectOption({ label: hostB });
|
||||
await privat.getByRole("textbox", { name: "Alias" }).fill("zu-b");
|
||||
// A erreicht B im Docker-Netz unter dem Dienstnamen auf Port 22.
|
||||
await privat.getByRole("textbox", { name: "HostName" }).fill("ziel-b");
|
||||
await privat.getByRole("spinbutton", { name: "Port" }).fill("22");
|
||||
await privat.getByRole("button", { name: "Zuweisen" }).click();
|
||||
await expect(privat).toBeHidden();
|
||||
await expect(page.getByText(`root@${hostA}`)).toBeVisible();
|
||||
|
||||
// 2b. Public Key auf B
|
||||
await page.getByRole("button", { name: "Ausrollen" }).nth(1).click();
|
||||
const oeffentlich = page.getByRole("dialog", { name: "Public Key zuweisen" });
|
||||
await oeffentlich.getByLabel("Ziel", { exact: true }).selectOption({ label: hostB });
|
||||
await oeffentlich.getByLabel(/Einschränkungen/).fill("no-pty,no-agent-forwarding");
|
||||
await oeffentlich.getByRole("button", { name: "Zuweisen" }).click();
|
||||
await expect(oeffentlich).toBeHidden();
|
||||
await expect(page.getByText(`root@${hostB}`)).toBeVisible();
|
||||
|
||||
// 3. Dry-Run und anwenden
|
||||
await page.getByRole("button", { name: "Dry-Run starten" }).click();
|
||||
await expect(page.getByText(`root@${hostB}:/root/.ssh/authorized_keys`)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("Host zu-b").first()).toBeVisible();
|
||||
// Kein Private Key im Browser.
|
||||
expect(await page.content()).not.toContain("PRIVATE KEY");
|
||||
await page.screenshot({ path: "docs/anleitung/bilder/kern-dry-run-desktop.png", fullPage: true });
|
||||
await page.getByRole("button", { name: /Änderung anwenden/ }).click();
|
||||
await expect(page.getByText("Ausgerollt.")).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
// 4. Verbindungstest von A nach B
|
||||
await page.getByRole("button", { name: "Verbindung testen" }).click();
|
||||
await expect(page.getByText("Erledigt.")).toBeVisible({ timeout: 30_000 });
|
||||
expect(aufZiel("ziel-a", "ssh -o BatchMode=yes zu-b id -un").trim()).toBe("root");
|
||||
await page.screenshot({ path: "docs/anleitung/bilder/kern-ausgerollt-desktop.png", fullPage: true });
|
||||
|
||||
await archivieren(page, hostA);
|
||||
await archivieren(page, hostB);
|
||||
});
|
||||
Reference in New Issue
Block a user