Merge branch 'phase/016-public-key-ausrollen'
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
@@ -18,6 +18,9 @@ import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { hostDetail, type HostDetail } from "@/server/queries/hosts";
|
||||
import { BootstrapAktionen } from "./BootstrapAktionen";
|
||||
import { sshdHinweise } from "@/lib/sshd-hinweise";
|
||||
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
||||
import { VerwendungsZustand } from "@/components/ausrollen/VerwendungsZustand";
|
||||
import { verwendungenAufHost } from "@/server/queries/ziele";
|
||||
import { HostKeyAktionen } from "./HostKeyAktionen";
|
||||
import { HostKeyEntscheidung } from "./HostKeyEntscheidung";
|
||||
import { bootstrapBefehlFuer } from "@/server/queries/bootstrap";
|
||||
@@ -95,6 +98,8 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri
|
||||
const archiviert = host.archiviertAm !== null;
|
||||
const verwalten = darf(benutzer.rolle, "hostsVerwalten") && !archiviert;
|
||||
const darfEntscheiden = darf(benutzer.rolle, "hostKeyEntscheiden") && !archiviert;
|
||||
const darfAusrollen = darf(benutzer.rolle, "publicKeyAusrollen") && !archiviert;
|
||||
const verwendungen = await verwendungenAufHost(host.id);
|
||||
const jetzt = new Date();
|
||||
|
||||
return (
|
||||
@@ -217,7 +222,7 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri
|
||||
|
||||
<Karte
|
||||
titel="Schlüssel auf diesem Host"
|
||||
untertitel={`${host.publicVerwendungen} Public-Key-Verwendungen · ${host.privateVerwendungen} Private-Key-Verwendungen`}
|
||||
untertitel="Public Keys in authorized_keys und Private Keys, die sshkv hier verwaltet"
|
||||
>
|
||||
{host.zielkonten.length > 0 && (
|
||||
<p className="text-muted mb-4 text-[14px]">
|
||||
@@ -230,11 +235,64 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
<Leerzustand
|
||||
icon={<KeyRound className="size-5" />}
|
||||
titel="Folgt mit #016"
|
||||
text="Hier erscheinen die Public und Private Keys, die sshkv auf diesem Host verwaltet."
|
||||
/>
|
||||
{verwendungen.oeffentlich.length === 0 && verwendungen.privat.length === 0 ? (
|
||||
<Leerzustand
|
||||
icon={<KeyRound className="size-5" />}
|
||||
titel="Noch keine Schlüssel zugewiesen"
|
||||
text="Zuweisen geht über die Seite eines Schlüssels („+ Ausrollen“)."
|
||||
/>
|
||||
) : (
|
||||
<ul className="flex flex-col">
|
||||
{verwendungen.oeffentlich.map((v) => (
|
||||
<li
|
||||
key={v.id}
|
||||
className="border-border flex flex-col gap-1 border-b py-3 first:pt-0 last:border-b-0 sm:flex-row sm:items-center sm:justify-between sm:gap-4"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<a
|
||||
href={`/schluessel/${v.schluessel.id}`}
|
||||
className="text-text hover:text-accent font-medium"
|
||||
>
|
||||
{v.schluessel.name}
|
||||
</a>
|
||||
<span className="text-muted block font-mono text-[12.5px] break-all">
|
||||
Public · {v.zielbenutzer}@{host.name}
|
||||
{v.hostgruppe ? ` · über ${v.hostgruppe.name}` : ""}
|
||||
{v.optionen ? ` · ${v.optionen}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
<VerwendungsZustand zustand={v.zustand} />
|
||||
</li>
|
||||
))}
|
||||
{verwendungen.privat.map((v) => (
|
||||
<li
|
||||
key={v.id}
|
||||
className="border-border flex flex-col gap-1 border-b py-3 first:pt-0 last:border-b-0 sm:flex-row sm:items-center sm:justify-between sm:gap-4"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<a
|
||||
href={`/schluessel/${v.schluessel.id}`}
|
||||
className="text-text hover:text-accent font-medium"
|
||||
>
|
||||
{v.schluessel.name}
|
||||
</a>
|
||||
<span className="text-muted block font-mono text-[12.5px] break-all">
|
||||
Private · {v.zielbenutzer}@{host.name}:{v.pfad}
|
||||
</span>
|
||||
</span>
|
||||
<VerwendungsZustand zustand={v.zustand} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{darfAusrollen && verwendungen.oeffentlich.length > 0 && host.bootstrapAm && (
|
||||
<div className="border-border mt-5 border-t pt-5">
|
||||
<AusrollenPanel
|
||||
auswahl={{ hostId: host.id }}
|
||||
offen={verwendungen.oeffentlich.filter((v) => v.zustand !== "AUSGEROLLT").length}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Karte>
|
||||
|
||||
{verwalten && (
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { Play, ScanSearch } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { DiffBox } from "@/components/ui/DiffBox";
|
||||
import { laeuftNoch, useVorgang } from "@/components/vorgang/useVorgang";
|
||||
import { ausrollenAktion, trockenlaufAktion, type AusrollAuswahl } from "@/server/actions/verwendungen";
|
||||
import type { DiffZeile } from "@/lib/ssh/zeilen-diff";
|
||||
|
||||
/**
|
||||
* Dry-Run-Vorschau und Ausfuehren (#016). Was angezeigt wird, ist genau das, was der Worker
|
||||
* 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 PlanZiel = {
|
||||
hostName: string;
|
||||
zielbenutzer: string;
|
||||
pfad?: string;
|
||||
geaendert: boolean;
|
||||
diff: DiffZeile[];
|
||||
hinweise: string[];
|
||||
fehler?: string;
|
||||
};
|
||||
|
||||
export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; offen: number }) {
|
||||
const router = useRouter();
|
||||
const [trockenlaufId, setTrockenlaufId] = useState<string>();
|
||||
const [ausrollenId, setAusrollenId] = useState<string>();
|
||||
const [fehler, setFehler] = useState<string>();
|
||||
const [startet, starten] = useTransition();
|
||||
const trockenlauf = useVorgang(trockenlaufId);
|
||||
const ausrollen = useVorgang(ausrollenId);
|
||||
|
||||
useEffect(() => {
|
||||
if (ausrollen.stand && !laeuftNoch(ausrollen.stand)) router.refresh();
|
||||
}, [ausrollen.stand, router]);
|
||||
|
||||
const plan = (trockenlauf.stand?.plan as { ziele?: PlanZiel[] } | null)?.ziele;
|
||||
const aenderungen = plan?.filter((z) => z.geaendert && !z.fehler).length ?? 0;
|
||||
const laeuft = startet || laeuftNoch(trockenlauf.stand) || laeuftNoch(ausrollen.stand);
|
||||
const fertig = ausrollen.stand?.status === "ERLEDIGT";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
icon={<ScanSearch className="size-4" />}
|
||||
disabled={laeuft}
|
||||
onClick={() =>
|
||||
starten(async () => {
|
||||
setFehler(undefined);
|
||||
setAusrollenId(undefined);
|
||||
const antwort = await trockenlaufAktion(auswahl);
|
||||
if (antwort.fehler) setFehler(antwort.fehler);
|
||||
else setTrockenlaufId(antwort.vorgangId);
|
||||
})
|
||||
}
|
||||
>
|
||||
{laeuftNoch(trockenlauf.stand) ? "Trockenlauf läuft …" : "Dry-Run starten"}
|
||||
</Button>
|
||||
{offen > 0 && !plan && (
|
||||
<span className="text-accent text-[13.5px]">{offen} Zuweisung(en) noch nicht ausgerollt.</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(fehler || trockenlauf.fehler || trockenlauf.stand?.status === "FEHLGESCHLAGEN") && (
|
||||
<p role="status" className="text-danger text-[14px]">
|
||||
{fehler ?? trockenlauf.fehler ?? trockenlauf.stand?.fehler}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{plan?.map((ziel) => (
|
||||
<div key={`${ziel.hostName}-${ziel.zielbenutzer}`} className="flex flex-col gap-2">
|
||||
{ziel.fehler ? (
|
||||
<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>
|
||||
) : (
|
||||
<DiffBox
|
||||
datei={`${ziel.zielbenutzer}@${ziel.hostName}:${ziel.pfad ?? "~/.ssh/authorized_keys"}`}
|
||||
zeilen={ziel.geaendert ? ziel.diff : [{ art: "kontext", text: "Keine Änderung nötig." }]}
|
||||
/>
|
||||
)}
|
||||
{ziel.hinweise.map((h) => (
|
||||
<p key={h} className="text-accent text-[13px]">
|
||||
▲ {h}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{plan && aenderungen > 0 && !fertig && (
|
||||
<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>)
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
variante="primaer"
|
||||
icon={<Play className="size-4" />}
|
||||
disabled={laeuft}
|
||||
onClick={() =>
|
||||
starten(async () => {
|
||||
const antwort = await ausrollenAktion(trockenlaufId!);
|
||||
if (antwort.fehler) setFehler(antwort.fehler);
|
||||
else setAusrollenId(antwort.vorgangId);
|
||||
})
|
||||
}
|
||||
>
|
||||
{laeuftNoch(ausrollen.stand) ? "Wird ausgerollt …" : `Änderung anwenden (${aenderungen})`}
|
||||
</Button>
|
||||
<Button disabled={laeuft} onClick={() => setTrockenlaufId(undefined)}>
|
||||
Verwerfen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{plan && aenderungen === 0 && (
|
||||
<p className="text-ok text-[14px]">Alles ist bereits auf dem Soll-Stand.</p>
|
||||
)}
|
||||
{ausrollen.stand?.status === "FEHLGESCHLAGEN" && (
|
||||
<p role="status" className="text-danger text-[14px]">
|
||||
{ausrollen.stand.fehler}
|
||||
</p>
|
||||
)}
|
||||
{fertig && <p className="text-ok text-[14px]">Ausgerollt.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Feld, Formularfehler } from "@/components/ui";
|
||||
import { Auswahlliste } from "@/components/ui/Feld";
|
||||
import { useFormularAktion } from "@/components/hosts/useFormularAktion";
|
||||
import { publicZuweisenAktion } from "@/server/actions/verwendungen";
|
||||
import type { ZielAuswahl } from "@/server/queries/ziele";
|
||||
|
||||
/**
|
||||
* Public Key einem Host oder einer Hostgruppe zuweisen (#016). Die Zuweisung allein aendert
|
||||
* noch nichts auf dem Host – erst Trockenlauf und Ausrollen.
|
||||
*/
|
||||
export function PublicZuweisenFormular({
|
||||
schluesselId,
|
||||
auswahl,
|
||||
beiErfolg,
|
||||
}: {
|
||||
schluesselId: string;
|
||||
auswahl: ZielAuswahl;
|
||||
beiErfolg?: () => void;
|
||||
}) {
|
||||
const { zustand, absenden, laeuft } = useFormularAktion(async (vorher, formular) => {
|
||||
const ergebnis = await publicZuweisenAktion(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} />
|
||||
<Auswahlliste beschriftung="Ziel" name="ziel" defaultValue="" required>
|
||||
<option value="" disabled>
|
||||
Host oder Hostgruppe wählen …
|
||||
</option>
|
||||
<optgroup label="Hosts">
|
||||
{auswahl.hosts.map((h) => (
|
||||
<option key={h.id} value={`host:${h.id}`}>
|
||||
{h.name}
|
||||
{h.bereit ? "" : " (noch kein Bootstrap)"}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
{auswahl.gruppen.length > 0 && (
|
||||
<optgroup label="Hostgruppen">
|
||||
{auswahl.gruppen.map((g) => (
|
||||
<option key={g.id} value={`gruppe:${g.id}`}>
|
||||
{g.name} ({g.mitglieder} Hosts)
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</Auswahlliste>
|
||||
{zustand.feldfehler?.["ziel"] && (
|
||||
<p className="text-danger -mt-2 text-[13px]">{zustand.feldfehler["ziel"]}</p>
|
||||
)}
|
||||
<Feld
|
||||
beschriftung="Zielbenutzer"
|
||||
name="zielbenutzer"
|
||||
defaultValue="root"
|
||||
mono
|
||||
required
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Einschränkungen in authorized_keys (optional)"
|
||||
name="optionen"
|
||||
mono
|
||||
autoComplete="off"
|
||||
placeholder='restrict,from="10.0.0.12"'
|
||||
hinweis='z. B. restrict, from="IP", command="…", no-pty, no-port-forwarding'
|
||||
/>
|
||||
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||||
{laeuft ? "Speichere …" : "Zuweisen"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { VerwendungsZustand as Zustand } from "@/generated/prisma/enums";
|
||||
import { clsx } from "clsx";
|
||||
|
||||
/** Zustand einer Verwendung – immer Punkt und Text, nie nur Farbe (#016). */
|
||||
const ANZEIGE: Record<Zustand, { text: string; klasse: string; punkt: string }> = {
|
||||
GEPLANT: { text: "noch nicht ausgerollt", klasse: "text-accent", punkt: "○" },
|
||||
AUSGEROLLT: { text: "synchron", klasse: "text-ok", punkt: "●" },
|
||||
ABWEICHUNG: { text: "Drift", klasse: "text-danger", punkt: "●" },
|
||||
ENTFERNEN: { text: "wird entfernt", klasse: "text-accent", punkt: "○" },
|
||||
ENTFERNT: { text: "entfernt", klasse: "text-muted", punkt: "○" },
|
||||
FEHLGESCHLAGEN: { text: "fehlgeschlagen", klasse: "text-danger", punkt: "●" },
|
||||
};
|
||||
|
||||
export function VerwendungsZustand({ zustand, className }: { zustand: Zustand; className?: string }) {
|
||||
const anzeige = ANZEIGE[zustand];
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
"inline-flex items-center gap-1.5 text-[13px] whitespace-nowrap",
|
||||
anzeige.klasse,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span aria-hidden>{anzeige.punkt}</span>
|
||||
{anzeige.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/** Was /api/vorgaenge/[id] liefert (#009, #016). */
|
||||
export type VorgangsStand = {
|
||||
readonly id: string;
|
||||
readonly status: string;
|
||||
readonly fehler: string | null;
|
||||
readonly plan: unknown;
|
||||
readonly ergebnis: unknown;
|
||||
};
|
||||
|
||||
const LAUFEND = new Set(["WARTEND", "LAEUFT"]);
|
||||
|
||||
export function laeuftNoch(stand: VorgangsStand | undefined): boolean {
|
||||
return stand !== undefined && LAUFEND.has(stand.status);
|
||||
}
|
||||
|
||||
/** Fragt einen Vorgang jede Sekunde ab, bis er fertig ist. */
|
||||
export function useVorgang(vorgangId: string | undefined): { stand?: VorgangsStand; fehler?: string } {
|
||||
// Der Stand merkt sich seine Vorgangs-ID: so zeigt ein neuer Vorgang nie den alten Stand,
|
||||
// ohne dass der Effekt synchron zuruecksetzen muss.
|
||||
const [stand, setStand] = useState<VorgangsStand>();
|
||||
const [fehler, setFehler] = useState<{ id: string; text: string }>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!vorgangId) return;
|
||||
let abgebrochen = false;
|
||||
void (async () => {
|
||||
while (!abgebrochen) {
|
||||
const antwort = await fetch(`/api/vorgaenge/${vorgangId}`, { cache: "no-store" });
|
||||
if (!antwort.ok) {
|
||||
setFehler({ id: vorgangId, text: "Status des Vorgangs nicht abrufbar." });
|
||||
return;
|
||||
}
|
||||
const neu = (await antwort.json()) as VorgangsStand;
|
||||
if (abgebrochen) return;
|
||||
setStand(neu);
|
||||
if (!LAUFEND.has(neu.status)) return;
|
||||
await new Promise((weiter) => setTimeout(weiter, 1000));
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
abgebrochen = true;
|
||||
};
|
||||
}, [vorgangId]);
|
||||
|
||||
return {
|
||||
...(stand && stand.id === vorgangId ? { stand } : {}),
|
||||
...(fehler && fehler.id === vorgangId ? { fehler: fehler.text } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use server";
|
||||
|
||||
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 {
|
||||
VerwendungsFehler,
|
||||
ausrollenStarten,
|
||||
publicVerwendungAnlegen,
|
||||
publicVerwendungEntfernen,
|
||||
trockenlaufStarten,
|
||||
} from "@/server/services/verwendungen";
|
||||
|
||||
/** Public-Key-Zuweisungen und Ausrollen (#016). Duenn: Recht, Eingabe, Service. */
|
||||
|
||||
export type VorgangsAntwort = { readonly vorgangId?: string; readonly fehler?: string };
|
||||
|
||||
function alsAntwort(fehler: unknown): { fehler: string } {
|
||||
if (
|
||||
fehler instanceof VerwendungsFehler ||
|
||||
fehler instanceof RechteFehler ||
|
||||
fehler instanceof BestaetigungFehler
|
||||
) {
|
||||
return { fehler: fehler.message };
|
||||
}
|
||||
throw fehler;
|
||||
}
|
||||
|
||||
export async function publicZuweisenAktion(
|
||||
_vorher: FormularZustand,
|
||||
formular: FormData,
|
||||
): Promise<FormularZustand> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("publicKeyAusrollen");
|
||||
const schluesselId = z.string().min(1).max(64).parse(textfeld(formular, "schluesselId"));
|
||||
// „host:<id>“ oder „gruppe:<id>“ aus einer einzigen Auswahlliste.
|
||||
const ziel = textfeld(formular, "ziel") ?? "";
|
||||
const [art, id] = ziel.split(":");
|
||||
if (!id || (art !== "host" && art !== "gruppe"))
|
||||
return { feldfehler: { ziel: "Bitte ein Ziel wählen." } };
|
||||
await publicVerwendungAnlegen(
|
||||
{
|
||||
schluesselId,
|
||||
hostId: art === "host" ? id : null,
|
||||
hostgruppeId: art === "gruppe" ? id : null,
|
||||
zielbenutzer: (textfeld(formular, "zielbenutzer") ?? "").trim(),
|
||||
optionen: textfeld(formular, "optionen") ?? null,
|
||||
},
|
||||
akteurVon(benutzer),
|
||||
);
|
||||
revalidatePath(`/schluessel/${schluesselId}`);
|
||||
return {};
|
||||
} catch (fehler) {
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function publicEntfernenAktion(
|
||||
verwendungId: string,
|
||||
schluesselId: string,
|
||||
): Promise<{ fehler?: string }> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("publicKeyAusrollen");
|
||||
await publicVerwendungEntfernen(z.string().min(1).max(64).parse(verwendungId), akteurVon(benutzer));
|
||||
revalidatePath(`/schluessel/${schluesselId}`);
|
||||
return {};
|
||||
} catch (fehler) {
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export type AusrollAuswahl = { readonly schluesselId: string } | { readonly hostId: string };
|
||||
|
||||
export async function trockenlaufAktion(auswahl: AusrollAuswahl): Promise<VorgangsAntwort> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("publicKeyAusrollen");
|
||||
const id = z.string().min(1).max(64);
|
||||
return {
|
||||
vorgangId: await trockenlaufStarten(
|
||||
"schluesselId" in auswahl
|
||||
? { schluesselId: id.parse(auswahl.schluesselId) }
|
||||
: { hostId: id.parse(auswahl.hostId) },
|
||||
akteurVon(benutzer),
|
||||
),
|
||||
};
|
||||
} catch (fehler) {
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function ausrollenAktion(trockenlaufId: string): Promise<VorgangsAntwort> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("publicKeyAusrollen");
|
||||
return {
|
||||
vorgangId: await ausrollenStarten(z.string().min(1).max(64).parse(trockenlaufId), akteurVon(benutzer)),
|
||||
};
|
||||
} catch (fehler) {
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/** Auswahl fuer „Public Key ausrollen auf …“ (#016): Hosts mit Bootstrap und Hostgruppen. */
|
||||
export async function zielAuswahl() {
|
||||
const [hosts, gruppen] = await Promise.all([
|
||||
prisma.host.findMany({
|
||||
where: { archiviertAm: null },
|
||||
select: { id: true, name: true, bootstrapAm: true },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
prisma.hostgruppe.findMany({
|
||||
select: { id: true, name: true, _count: { select: { mitglieder: true } } },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
hosts: hosts.map((h) => ({ id: h.id, name: h.name, bereit: h.bootstrapAm !== null })),
|
||||
gruppen: gruppen.map((g) => ({ id: g.id, name: g.name, mitglieder: g._count.mitglieder })),
|
||||
};
|
||||
}
|
||||
|
||||
export type ZielAuswahl = Awaited<ReturnType<typeof zielAuswahl>>;
|
||||
|
||||
/** Was sshkv auf einem Host verwaltet (#016): Public Keys direkt und ueber Gruppen. */
|
||||
export async function verwendungenAufHost(hostId: string) {
|
||||
const [oeffentlich, privat] = await Promise.all([
|
||||
prisma.publicVerwendung.findMany({
|
||||
where: {
|
||||
zustand: { not: "ENTFERNT" },
|
||||
OR: [{ hostId }, { hostgruppe: { mitglieder: { some: { hostId } } } }],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
zielbenutzer: true,
|
||||
optionen: true,
|
||||
zustand: true,
|
||||
hostgruppe: { select: { name: true } },
|
||||
schluessel: { select: { id: true, name: true, typ: true, fingerprint: true } },
|
||||
},
|
||||
orderBy: [{ zielbenutzer: "asc" }, { angelegtAm: "asc" }],
|
||||
}),
|
||||
prisma.privateVerwendung.findMany({
|
||||
where: { hostId, zustand: { not: "ENTFERNT" } },
|
||||
select: {
|
||||
id: true,
|
||||
zielbenutzer: true,
|
||||
pfad: true,
|
||||
zustand: true,
|
||||
schluessel: { select: { id: true, name: true, typ: true, fingerprint: true } },
|
||||
},
|
||||
orderBy: [{ zielbenutzer: "asc" }, { pfad: "asc" }],
|
||||
}),
|
||||
]);
|
||||
return { oeffentlich, privat };
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { Host } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { SollEintrag } from "@/lib/ssh/authorized-keys";
|
||||
|
||||
/**
|
||||
* Der Soll-Zustand einer authorized_keys (#016): welche Public Keys gehoeren in den
|
||||
* verwalteten Block eines Benutzers auf einem Host – direkt zugewiesen oder ueber eine
|
||||
* Hostgruppe. Laeuft im Worker und im Web (Vorschau), deshalb hier und nicht in src/worker.
|
||||
*/
|
||||
|
||||
/** Dienstbenutzer auf den Hosts (Freigabe Frage 1); gleiche Regel wie im Worker. */
|
||||
export function anmeldebenutzerVon(host: Pick<Host, "anmeldeart">): string {
|
||||
return host.anmeldeart === "ROOT" ? "root" : "sshkv";
|
||||
}
|
||||
|
||||
/** Zustaende, in denen eine Verwendung zum Soll gehoert. */
|
||||
const IM_SOLL = ["GEPLANT", "AUSGEROLLT", "ABWEICHUNG", "FEHLGESCHLAGEN"] as const;
|
||||
|
||||
export type Soll = {
|
||||
readonly eintraege: SollEintrag[];
|
||||
/** Fingerprints, die nie aus dem Block fallen duerfen (Deployment-Key). */
|
||||
readonly pflicht: string[];
|
||||
/** Verwendungen, die in diesem Soll stecken oder entfernt werden – fuer den Zustand danach. */
|
||||
readonly verwendungIds: { readonly drin: string[]; readonly raus: string[] };
|
||||
};
|
||||
|
||||
async function deploymentKeyFuer(host: Pick<Host, "id" | "deploymentSchluesselId">) {
|
||||
if (host.deploymentSchluesselId) {
|
||||
return prisma.schluessel.findUnique({
|
||||
where: { id: host.deploymentSchluesselId },
|
||||
select: { publicKey: true, fingerprint: true },
|
||||
});
|
||||
}
|
||||
const gruppe = await prisma.hostgruppeMitglied.findFirst({
|
||||
where: { hostId: host.id, hostgruppe: { deploymentSchluesselId: { not: null } } },
|
||||
select: {
|
||||
hostgruppe: { select: { deploymentSchluessel: { select: { publicKey: true, fingerprint: true } } } },
|
||||
},
|
||||
});
|
||||
if (gruppe?.hostgruppe.deploymentSchluessel) return gruppe.hostgruppe.deploymentSchluessel;
|
||||
return prisma.schluessel.findFirst({
|
||||
where: { kategorie: "DEPLOYMENT", instanzStandard: true, status: "AKTIV" },
|
||||
select: { publicKey: true, fingerprint: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function sollFuer(hostId: string, zielbenutzer: string): Promise<Soll> {
|
||||
const host = await prisma.host.findUniqueOrThrow({
|
||||
where: { id: hostId },
|
||||
select: { id: true, anmeldeart: true, deploymentSchluesselId: true },
|
||||
});
|
||||
const verwendungen = await prisma.publicVerwendung.findMany({
|
||||
where: {
|
||||
zielbenutzer,
|
||||
OR: [{ hostId }, { hostgruppe: { mitglieder: { some: { hostId } } } }],
|
||||
zustand: { not: "ENTFERNT" },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
optionen: true,
|
||||
zustand: true,
|
||||
angelegtAm: true,
|
||||
schluessel: { select: { publicKey: true, fingerprint: true, status: true } },
|
||||
},
|
||||
// Deterministische Reihenfolge im Block: aelteste Zuweisung zuerst.
|
||||
orderBy: [{ angelegtAm: "asc" }, { id: "asc" }],
|
||||
});
|
||||
|
||||
const eintraege: SollEintrag[] = [];
|
||||
const pflicht: string[] = [];
|
||||
if (zielbenutzer === anmeldebenutzerVon(host)) {
|
||||
const deployment = await deploymentKeyFuer(host);
|
||||
if (deployment) {
|
||||
eintraege.push({ publicKey: deployment.publicKey, fingerprint: deployment.fingerprint });
|
||||
pflicht.push(deployment.fingerprint);
|
||||
}
|
||||
}
|
||||
|
||||
const drin: string[] = [];
|
||||
const raus: string[] = [];
|
||||
for (const verwendung of verwendungen) {
|
||||
const aktiv = verwendung.schluessel.status === "AKTIV" || verwendung.schluessel.status === "ROTIERT";
|
||||
if ((IM_SOLL as readonly string[]).includes(verwendung.zustand) && aktiv) {
|
||||
eintraege.push({
|
||||
publicKey: verwendung.schluessel.publicKey,
|
||||
fingerprint: verwendung.schluessel.fingerprint,
|
||||
optionen: verwendung.optionen,
|
||||
});
|
||||
drin.push(verwendung.id);
|
||||
} else {
|
||||
raus.push(verwendung.id);
|
||||
}
|
||||
}
|
||||
return { eintraege, pflicht, verwendungIds: { drin, raus } };
|
||||
}
|
||||
|
||||
export type Ziel = { readonly hostId: string; readonly zielbenutzer: string };
|
||||
|
||||
/** Alle (Host, Benutzer)-Paare, die eine Public-Key-Verwendung betrifft – Gruppen aufgeloest. */
|
||||
export async function zieleVonVerwendungen(where: {
|
||||
schluesselId?: string;
|
||||
ids?: string[];
|
||||
hostId?: string;
|
||||
}): Promise<Ziel[]> {
|
||||
const verwendungen = await prisma.publicVerwendung.findMany({
|
||||
where: {
|
||||
...(where.schluesselId ? { schluesselId: where.schluesselId } : {}),
|
||||
...(where.ids ? { id: { in: where.ids } } : {}),
|
||||
...(where.hostId
|
||||
? {
|
||||
OR: [
|
||||
{ hostId: where.hostId },
|
||||
{ hostgruppe: { mitglieder: { some: { hostId: where.hostId } } } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
zustand: { not: "ENTFERNT" },
|
||||
},
|
||||
select: {
|
||||
zielbenutzer: true,
|
||||
hostId: true,
|
||||
hostgruppe: {
|
||||
select: { mitglieder: { select: { hostId: true, host: { select: { archiviertAm: true } } } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
const ziele = new Map<string, Ziel>();
|
||||
for (const verwendung of verwendungen) {
|
||||
const alleHostIds = verwendung.hostId
|
||||
? [verwendung.hostId]
|
||||
: (verwendung.hostgruppe?.mitglieder.filter((m) => !m.host.archiviertAm).map((m) => m.hostId) ?? []);
|
||||
// Bei der Auswahl nach Host nur diesen Host, auch wenn die Verwendung an einer Gruppe haengt.
|
||||
const hostIds = where.hostId ? alleHostIds.filter((id) => id === where.hostId) : alleHostIds;
|
||||
for (const hostId of hostIds) {
|
||||
ziele.set(`${hostId}\u0000${verwendung.zielbenutzer}`, {
|
||||
hostId,
|
||||
zielbenutzer: verwendung.zielbenutzer,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...ziele.values()];
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { BENUTZERNAME_MUSTER } from "@/lib/shell";
|
||||
import { OptionenFehler, optionenPruefen } from "@/lib/ssh/optionen";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
import { zieleVonVerwendungen, type Ziel } from "@/server/services/soll";
|
||||
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||
|
||||
/**
|
||||
* Public-Key-Zuweisungen (#016): anlegen, zum Entfernen vormerken, Trockenlauf und
|
||||
* Ausfuehren anstossen. Geschrieben wird nur im Worker.
|
||||
*/
|
||||
export class VerwendungsFehler extends Error {
|
||||
constructor(nachricht: string) {
|
||||
super(nachricht);
|
||||
this.name = "VerwendungsFehler";
|
||||
}
|
||||
}
|
||||
|
||||
type Akteur = { readonly id: string; readonly name: string };
|
||||
|
||||
export type PublicZuweisung = {
|
||||
readonly schluesselId: string;
|
||||
readonly hostId?: string | null;
|
||||
readonly hostgruppeId?: string | null;
|
||||
readonly zielbenutzer: string;
|
||||
readonly optionen?: string | null;
|
||||
};
|
||||
|
||||
export async function publicVerwendungAnlegen(eingabe: PublicZuweisung, akteur: Akteur): Promise<string> {
|
||||
if (!BENUTZERNAME_MUSTER.test(eingabe.zielbenutzer)) {
|
||||
throw new VerwendungsFehler(
|
||||
"Ungültiger Zielbenutzer (Kleinbuchstaben, Ziffern, _ und -, höchstens 32 Zeichen).",
|
||||
);
|
||||
}
|
||||
if (Boolean(eingabe.hostId) === Boolean(eingabe.hostgruppeId)) {
|
||||
throw new VerwendungsFehler("Bitte genau einen Host oder eine Hostgruppe wählen.");
|
||||
}
|
||||
let optionen: string | null = null;
|
||||
try {
|
||||
optionen = optionenPruefen(eingabe.optionen ?? "") || null;
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof OptionenFehler) throw new VerwendungsFehler(fehler.message);
|
||||
throw fehler;
|
||||
}
|
||||
const schluessel = await prisma.schluessel.findUnique({
|
||||
where: { id: eingabe.schluesselId },
|
||||
select: { status: true, fingerprint: true, kategorie: true },
|
||||
});
|
||||
if (!schluessel) throw new VerwendungsFehler("Diesen Schlüssel gibt es nicht.");
|
||||
if (schluessel.status !== "AKTIV")
|
||||
throw new VerwendungsFehler("Nur aktive Schlüssel lassen sich zuweisen.");
|
||||
if (schluessel.kategorie === "DEPLOYMENT") {
|
||||
throw new VerwendungsFehler("Der Deployment-Key wird von sshkv selbst verwaltet.");
|
||||
}
|
||||
const ziel = eingabe.hostId
|
||||
? await prisma.host.findUnique({
|
||||
where: { id: eingabe.hostId },
|
||||
select: { name: true, archiviertAm: true },
|
||||
})
|
||||
: await prisma.hostgruppe.findUnique({ where: { id: eingabe.hostgruppeId! }, select: { name: true } });
|
||||
if (!ziel || ("archiviertAm" in ziel && ziel.archiviertAm))
|
||||
throw new VerwendungsFehler("Das Ziel gibt es nicht (mehr).");
|
||||
|
||||
try {
|
||||
const verwendung = await prisma.publicVerwendung.create({
|
||||
data: {
|
||||
schluesselId: eingabe.schluesselId,
|
||||
hostId: eingabe.hostId ?? null,
|
||||
hostgruppeId: eingabe.hostgruppeId ?? null,
|
||||
zielbenutzer: eingabe.zielbenutzer,
|
||||
optionen,
|
||||
angelegtVonId: akteur.id,
|
||||
},
|
||||
});
|
||||
await protokollieren({
|
||||
akteur,
|
||||
aktion: "verwendung.public.angelegt",
|
||||
ergebnis: "ERFOLG",
|
||||
hostName: eingabe.hostId ? ziel.name : null,
|
||||
zielbenutzer: eingabe.zielbenutzer,
|
||||
fingerprint: schluessel.fingerprint,
|
||||
details: { hostgruppe: eingabe.hostgruppeId ? ziel.name : null, optionen },
|
||||
});
|
||||
return verwendung.id;
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof Prisma.PrismaClientKnownRequestError && fehler.code === "P2002") {
|
||||
throw new VerwendungsFehler("Dieser Schlüssel ist dort für diesen Benutzer bereits zugewiesen.");
|
||||
}
|
||||
throw fehler;
|
||||
}
|
||||
}
|
||||
|
||||
/** Merkt eine Verwendung zum Entfernen vor; wirksam mit dem naechsten Ausrollen. */
|
||||
export async function publicVerwendungEntfernen(id: string, akteur: Akteur): Promise<void> {
|
||||
const verwendung = await prisma.publicVerwendung.findUnique({
|
||||
where: { id },
|
||||
include: { schluessel: { select: { fingerprint: true } }, host: { select: { name: true } } },
|
||||
});
|
||||
if (!verwendung || verwendung.zustand === "ENTFERNT")
|
||||
throw new VerwendungsFehler("Diese Zuweisung gibt es nicht.");
|
||||
// Nie ausgerollt: einfach weg, auf dem Host steht noch nichts.
|
||||
const nieAusgerollt = verwendung.zustand === "GEPLANT" && !verwendung.zuletztAngewendetAm;
|
||||
await prisma.publicVerwendung.update({
|
||||
where: { id },
|
||||
data: { zustand: nieAusgerollt ? "ENTFERNT" : "ENTFERNEN" },
|
||||
});
|
||||
await protokollieren({
|
||||
akteur,
|
||||
aktion: "verwendung.public.entfernen",
|
||||
ergebnis: "ERFOLG",
|
||||
hostName: verwendung.host?.name ?? null,
|
||||
zielbenutzer: verwendung.zielbenutzer,
|
||||
fingerprint: verwendung.schluessel.fingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
/** Trockenlauf fuer die Ziele eines Schluessels oder fuer ausgewaehlte Verwendungen. */
|
||||
export async function trockenlaufStarten(
|
||||
auswahl: { schluesselId?: string; hostId?: string; verwendungIds?: string[]; ziele?: Ziel[] },
|
||||
akteur: Akteur,
|
||||
): Promise<string> {
|
||||
const ziele =
|
||||
auswahl.ziele ??
|
||||
(await zieleVonVerwendungen({
|
||||
...(auswahl.schluesselId ? { schluesselId: auswahl.schluesselId } : {}),
|
||||
...(auswahl.hostId ? { hostId: auswahl.hostId } : {}),
|
||||
...(auswahl.verwendungIds ? { ids: auswahl.verwendungIds } : {}),
|
||||
}));
|
||||
if (ziele.length === 0) throw new VerwendungsFehler("Es gibt nichts auszurollen.");
|
||||
return vorgangEinreihen({
|
||||
art: "TROCKENLAUF",
|
||||
angefordertVonId: akteur.id,
|
||||
schluesselId: auswahl.schluesselId ?? null,
|
||||
hostId: auswahl.hostId ?? null,
|
||||
eingabe: { ziele },
|
||||
});
|
||||
}
|
||||
|
||||
export async function ausrollenStarten(trockenlaufId: string, akteur: Akteur): Promise<string> {
|
||||
const trockenlauf = await prisma.vorgang.findUnique({ where: { id: trockenlaufId } });
|
||||
if (!trockenlauf || trockenlauf.art !== "TROCKENLAUF" || trockenlauf.status !== "ERLEDIGT") {
|
||||
throw new VerwendungsFehler("Erst den Trockenlauf abwarten.");
|
||||
}
|
||||
return vorgangEinreihen({
|
||||
art: "AUSROLLEN",
|
||||
angefordertVonId: akteur.id,
|
||||
schluesselId: trockenlauf.schluesselId,
|
||||
hostId: trockenlauf.hostId,
|
||||
eingabe: { trockenlaufId },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { Client } from "ssh2";
|
||||
import type { Host } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { BlockFehler, blockSetzen } from "@/lib/ssh/authorized-keys";
|
||||
import { diffVerdichten, type DiffZeile } from "@/lib/ssh/zeilen-diff";
|
||||
import { sollFuer, type Ziel } from "@/server/services/soll";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
import { VorgangsFehler, type Handler } from "@/worker/ausfuehren";
|
||||
import { authorizedKeysHolen, authorizedKeysSchreiben, inhaltHash } from "@/worker/ssh/authorized-keys-datei";
|
||||
import { hostVerbinden } from "@/worker/ssh/host";
|
||||
|
||||
/**
|
||||
* Public Keys ausrollen (#016) in zwei Schritten:
|
||||
*
|
||||
* 1. TROCKENLAUF: je Ziel (Host, Benutzer) die Datei lesen, den Block aus dem Soll setzen und
|
||||
* Diff, Hinweise und beide Hashes festhalten. Auf dem Host aendert sich nichts.
|
||||
* 2. AUSROLLEN: genau diesen Plan ausfuehren. Hat sich die Datei oder das Soll seitdem
|
||||
* geaendert, bricht das Ziel ab (optimistische Sperre ueber die Hashes) – der Mensch
|
||||
* soll nie etwas anderes schreiben, als er gesehen hat.
|
||||
*/
|
||||
|
||||
/** So lange darf ein Trockenlauf alt sein, bevor er ausgefuehrt wird. */
|
||||
export const TROCKENLAUF_GUELTIG_MS = 15 * 60 * 1000;
|
||||
|
||||
export type PlanZiel = {
|
||||
readonly hostId: string;
|
||||
readonly hostName: string;
|
||||
readonly zielbenutzer: string;
|
||||
readonly pfad?: string;
|
||||
readonly vorher?: string;
|
||||
readonly nachher?: string;
|
||||
readonly geaendert: boolean;
|
||||
readonly diff: DiffZeile[];
|
||||
readonly hinweise: string[];
|
||||
readonly fehler?: string;
|
||||
};
|
||||
|
||||
export type Plan = { readonly ziele: PlanZiel[] };
|
||||
|
||||
function hashVon(text: string): string {
|
||||
return createHash("sha256").update(Buffer.from(text, "utf8")).digest("hex");
|
||||
}
|
||||
|
||||
function fehlerText(fehler: unknown): string {
|
||||
if (fehler instanceof VorgangsFehler || fehler instanceof BlockFehler) return fehler.message;
|
||||
return "Interner Fehler – Details im Worker-Log.";
|
||||
}
|
||||
|
||||
function zieleLesen(eingabe: unknown): Ziel[] {
|
||||
const ziele = (eingabe as { ziele?: unknown } | null)?.ziele;
|
||||
if (!Array.isArray(ziele) || ziele.length === 0)
|
||||
throw new VorgangsFehler("Der Trockenlauf nennt keine Ziele.");
|
||||
return ziele.map((z) => {
|
||||
const ziel = z as Partial<Ziel>;
|
||||
if (typeof ziel.hostId !== "string" || typeof ziel.zielbenutzer !== "string") {
|
||||
throw new VorgangsFehler("Ungültiges Ziel im Trockenlauf.");
|
||||
}
|
||||
return { hostId: ziel.hostId, zielbenutzer: ziel.zielbenutzer };
|
||||
});
|
||||
}
|
||||
|
||||
/** Ziele nach Host gruppieren: je Host genau eine SSH-Verbindung. */
|
||||
async function jeHost<T>(
|
||||
ziele: readonly Ziel[],
|
||||
arbeit: (host: Host, verbindung: Client, benutzer: string) => Promise<T>,
|
||||
beiFehler: (host: Pick<Host, "id" | "name">, benutzer: string, fehler: unknown) => T,
|
||||
): Promise<T[]> {
|
||||
const nachHost = new Map<string, string[]>();
|
||||
for (const ziel of ziele)
|
||||
nachHost.set(ziel.hostId, [...(nachHost.get(ziel.hostId) ?? []), ziel.zielbenutzer]);
|
||||
const ergebnisse: T[] = [];
|
||||
for (const [hostId, benutzerListe] of nachHost) {
|
||||
const host = await prisma.host.findUnique({ where: { id: hostId } });
|
||||
if (!host || host.archiviertAm) {
|
||||
for (const benutzer of benutzerListe) {
|
||||
ergebnisse.push(
|
||||
beiFehler(
|
||||
{ id: hostId, name: host?.name ?? "?" },
|
||||
benutzer,
|
||||
new VorgangsFehler("Host ist archiviert oder fehlt."),
|
||||
),
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let verbindung: Client;
|
||||
try {
|
||||
verbindung = await hostVerbinden(host);
|
||||
} catch (fehler) {
|
||||
for (const benutzer of benutzerListe) ergebnisse.push(beiFehler(host, benutzer, fehler));
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
for (const benutzer of benutzerListe) {
|
||||
try {
|
||||
ergebnisse.push(await arbeit(host, verbindung, benutzer));
|
||||
} catch (fehler) {
|
||||
ergebnisse.push(beiFehler(host, benutzer, fehler));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
verbindung.end();
|
||||
}
|
||||
}
|
||||
return ergebnisse;
|
||||
}
|
||||
|
||||
export const trockenlaufHandler: Handler = async ({ vorgang }) => {
|
||||
const ziele = zieleLesen(vorgang.eingabe);
|
||||
const planZiele = await jeHost<PlanZiel>(
|
||||
ziele,
|
||||
async (host, verbindung, benutzer) => {
|
||||
const stand = await authorizedKeysHolen(verbindung, host, benutzer);
|
||||
const soll = await sollFuer(host.id, benutzer);
|
||||
const ergebnis = blockSetzen(stand.inhalt, soll.eintraege, soll.pflicht);
|
||||
return {
|
||||
hostId: host.id,
|
||||
hostName: host.name,
|
||||
zielbenutzer: benutzer,
|
||||
pfad: stand.pfad,
|
||||
vorher: stand.sha256,
|
||||
nachher: hashVon(ergebnis.inhalt),
|
||||
geaendert: ergebnis.geaendert || !stand.vorhanden,
|
||||
diff: diffVerdichten(ergebnis.diff),
|
||||
hinweise: ergebnis.hinweise,
|
||||
};
|
||||
},
|
||||
(host, benutzer, fehler) => ({
|
||||
hostId: host.id,
|
||||
hostName: host.name,
|
||||
zielbenutzer: benutzer,
|
||||
geaendert: false,
|
||||
diff: [],
|
||||
hinweise: [],
|
||||
fehler: fehlerText(fehler),
|
||||
}),
|
||||
);
|
||||
return { plan: { ziele: planZiele }, ergebnis: { ziele: planZiele.length } };
|
||||
};
|
||||
|
||||
export const ausrollenHandler: Handler = async ({ vorgang }) => {
|
||||
const trockenlaufId = (vorgang.eingabe as { trockenlaufId?: unknown } | null)?.trockenlaufId;
|
||||
if (typeof trockenlaufId !== "string") throw new VorgangsFehler("Kein Trockenlauf angegeben.");
|
||||
const trockenlauf = await prisma.vorgang.findUnique({ where: { id: trockenlaufId } });
|
||||
if (
|
||||
!trockenlauf ||
|
||||
trockenlauf.art !== "TROCKENLAUF" ||
|
||||
trockenlauf.status !== "ERLEDIGT" ||
|
||||
!trockenlauf.plan
|
||||
) {
|
||||
throw new VorgangsFehler("Der Trockenlauf ist nicht abgeschlossen.");
|
||||
}
|
||||
if (Date.now() - (trockenlauf.beendetAm ?? trockenlauf.angelegtAm).getTime() > TROCKENLAUF_GUELTIG_MS) {
|
||||
throw new VorgangsFehler("Der Trockenlauf ist älter als 15 Minuten. Bitte neu starten.");
|
||||
}
|
||||
const bereitsAusgefuehrt = await prisma.vorgang.findFirst({
|
||||
where: {
|
||||
art: "AUSROLLEN",
|
||||
id: { not: vorgang.id },
|
||||
status: { in: ["LAEUFT", "ERLEDIGT"] },
|
||||
eingabe: { equals: { trockenlaufId } },
|
||||
},
|
||||
});
|
||||
if (bereitsAusgefuehrt) throw new VorgangsFehler("Dieser Trockenlauf wurde bereits ausgeführt.");
|
||||
|
||||
const plan = trockenlauf.plan as unknown as Plan;
|
||||
const auszufuehren = plan.ziele.filter((ziel) => ziel.geaendert && !ziel.fehler);
|
||||
const akteur = vorgang.angefordertVonId
|
||||
? await prisma.benutzer.findUnique({
|
||||
where: { id: vorgang.angefordertVonId },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
: null;
|
||||
|
||||
type Ausgang = {
|
||||
readonly hostId: string;
|
||||
readonly zielbenutzer: string;
|
||||
readonly erfolg: boolean;
|
||||
readonly fehler?: string;
|
||||
};
|
||||
const geplant = new Map(auszufuehren.map((z) => [`${z.hostId}\u0000${z.zielbenutzer}`, z]));
|
||||
|
||||
const ausgaenge = await jeHost<Ausgang>(
|
||||
auszufuehren,
|
||||
async (host, verbindung, benutzer) => {
|
||||
const ziel = geplant.get(`${host.id}\u0000${benutzer}`)!;
|
||||
const stand = await authorizedKeysHolen(verbindung, host, benutzer);
|
||||
if (stand.sha256 !== ziel.vorher) {
|
||||
throw new VorgangsFehler(
|
||||
`authorized_keys von ${benutzer}@${host.name} wurde seit dem Trockenlauf geändert. Bitte neuen Trockenlauf starten.`,
|
||||
);
|
||||
}
|
||||
const soll = await sollFuer(host.id, benutzer);
|
||||
const ergebnis = blockSetzen(stand.inhalt, soll.eintraege, soll.pflicht);
|
||||
if (hashVon(ergebnis.inhalt) !== ziel.nachher) {
|
||||
throw new VorgangsFehler(
|
||||
`Die Zuweisungen für ${benutzer}@${host.name} haben sich seit dem Trockenlauf geändert. Bitte neuen Trockenlauf starten.`,
|
||||
);
|
||||
}
|
||||
await authorizedKeysSchreiben(verbindung, host, benutzer, ergebnis.inhalt, stand.sha256);
|
||||
await protokollieren({
|
||||
akteur: akteur ?? "System",
|
||||
aktion: "authorized_keys.geschrieben",
|
||||
ergebnis: "ERFOLG",
|
||||
hostName: host.name,
|
||||
zielbenutzer: benutzer,
|
||||
diff: ergebnis.diff
|
||||
.filter((z) => z.art !== "kontext")
|
||||
.map((z) => `${z.art === "hinzu" ? "+" : "-"} ${z.text}`)
|
||||
.join("\n"),
|
||||
details: {
|
||||
vorher: stand.sha256,
|
||||
nachher: inhaltHash(Buffer.from(ergebnis.inhalt, "utf8")),
|
||||
trockenlaufId,
|
||||
},
|
||||
});
|
||||
return { hostId: host.id, zielbenutzer: benutzer, erfolg: true };
|
||||
},
|
||||
(host, benutzer, fehler) => ({
|
||||
hostId: host.id,
|
||||
zielbenutzer: benutzer,
|
||||
erfolg: false,
|
||||
fehler: fehlerText(fehler),
|
||||
}),
|
||||
);
|
||||
|
||||
// Zustand der Verwendungen nachziehen – auch fuer Ziele ohne Aenderung, die schon stimmten.
|
||||
const fehlgeschlagen = new Set(
|
||||
ausgaenge.filter((a) => !a.erfolg).map((a) => `${a.hostId}\u0000${a.zielbenutzer}`),
|
||||
);
|
||||
// Fehlschlaege zuletzt: eine Gruppen-Verwendung, die auf einem Host scheitert, soll
|
||||
// FEHLGESCHLAGEN bleiben, auch wenn ein anderer Host danach erfolgreich war.
|
||||
const reihenfolge = plan.ziele
|
||||
.filter((z) => !z.fehler)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(fehlgeschlagen.has(`${a.hostId}\u0000${a.zielbenutzer}`)) -
|
||||
Number(fehlgeschlagen.has(`${b.hostId}\u0000${b.zielbenutzer}`)),
|
||||
);
|
||||
for (const ziel of reihenfolge) {
|
||||
const schluessel = `${ziel.hostId}\u0000${ziel.zielbenutzer}`;
|
||||
const soll = await sollFuer(ziel.hostId, ziel.zielbenutzer);
|
||||
const jetzt = new Date();
|
||||
if (fehlgeschlagen.has(schluessel)) {
|
||||
await prisma.publicVerwendung.updateMany({
|
||||
where: { id: { in: soll.verwendungIds.drin } },
|
||||
data: { zustand: "FEHLGESCHLAGEN" },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
await prisma.publicVerwendung.updateMany({
|
||||
where: { id: { in: soll.verwendungIds.drin } },
|
||||
data: { zustand: "AUSGEROLLT", zuletztAngewendetAm: jetzt },
|
||||
});
|
||||
await prisma.publicVerwendung.updateMany({
|
||||
where: { id: { in: soll.verwendungIds.raus }, zustand: "ENTFERNEN" },
|
||||
data: { zustand: "ENTFERNT", zuletztAngewendetAm: jetzt },
|
||||
});
|
||||
}
|
||||
|
||||
const fehler = ausgaenge.filter((a) => !a.erfolg);
|
||||
if (fehler.length > 0 && fehler.length === ausgaenge.length) {
|
||||
throw new VorgangsFehler(fehler.map((f) => f.fehler).join(" "));
|
||||
}
|
||||
return { ergebnis: { geschrieben: ausgaenge.filter((a) => a.erfolg).length, fehler } };
|
||||
};
|
||||
@@ -1,12 +1,15 @@
|
||||
import type { HandlerTabelle } from "@/worker/ausfuehren";
|
||||
import { ausrollenHandler, trockenlaufHandler } from "@/worker/ablaeufe/ausrollen";
|
||||
import { bootstrapHandler } from "@/worker/ablaeufe/bootstrap";
|
||||
import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey";
|
||||
|
||||
/**
|
||||
* Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Ausrollen (#016), Abgleich (#021) …
|
||||
* Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Abgleich (#021) …
|
||||
*/
|
||||
export const HANDLER: HandlerTabelle = {
|
||||
HOSTKEY_ABRUFEN: hostKeyAbrufenHandler,
|
||||
ERREICHBARKEIT: erreichbarkeitHandler,
|
||||
BOOTSTRAP: bootstrapHandler,
|
||||
TROCKENLAUF: trockenlaufHandler,
|
||||
AUSROLLEN: ausrollenHandler,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { Client } from "ssh2";
|
||||
import type { Host } from "@/generated/prisma/client";
|
||||
import { BENUTZERNAME_MUSTER, shellQuote } from "@/lib/shell";
|
||||
import { VorgangsFehler } from "@/worker/ausfuehren";
|
||||
import { befehlAusfuehren } from "@/worker/ssh/verbindung";
|
||||
|
||||
/**
|
||||
* authorized_keys eines Benutzers auf einem Host lesen und schreiben (#016).
|
||||
*
|
||||
* Wie beim Bootstrap: alles im Home des Zielbenutzers geschieht **als dieser Benutzer**
|
||||
* (`runuser`), damit ein Symlink root nie eine fremde Datei lesen oder schreiben laesst.
|
||||
* Der Hash der Datei wird auf dem Host berechnet und beim Schreiben dort erneut geprueft –
|
||||
* hat sich die Datei seit dem Trockenlauf geaendert, bricht der Vorgang ab.
|
||||
*/
|
||||
|
||||
/** Hash fuer „Datei gibt es nicht“. */
|
||||
export const KEINE_DATEI = "keine-datei";
|
||||
/** So viele Sicherungen bleiben je Datei liegen. */
|
||||
const SICHERUNGEN_BEHALTEN = 5;
|
||||
|
||||
const MARKE_FEHLT = "SSHKV-BENUTZER-FEHLT";
|
||||
const MARKE_GEAENDERT = "SSHKV-GEAENDERT";
|
||||
const MARKE_OK = "SSHKV-GESCHRIEBEN";
|
||||
|
||||
export type DateiStand = {
|
||||
readonly pfad: string;
|
||||
readonly vorhanden: boolean;
|
||||
readonly sha256: string;
|
||||
readonly inhalt: string;
|
||||
};
|
||||
|
||||
function praefix(host: Pick<Host, "anmeldeart">): string {
|
||||
return host.anmeldeart === "ROOT" ? "" : "sudo -n ";
|
||||
}
|
||||
|
||||
function benutzerPruefen(benutzer: string): void {
|
||||
if (!BENUTZERNAME_MUSTER.test(benutzer)) throw new VorgangsFehler(`Ungültiger Zielbenutzer „${benutzer}“.`);
|
||||
}
|
||||
|
||||
export function inhaltHash(inhalt: Buffer | null): string {
|
||||
return inhalt === null ? KEINE_DATEI : createHash("sha256").update(inhalt).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest die Datei. Ausgabeformat des Skripts: erste Zeile `<Home> <ja|nein>`, danach die Datei
|
||||
* unveraendert – so kommen auch Bytes an, die kein Text sind.
|
||||
*/
|
||||
export async function authorizedKeysHolen(
|
||||
verbindung: Client,
|
||||
host: Host,
|
||||
benutzer: string,
|
||||
): Promise<DateiStand> {
|
||||
benutzerPruefen(benutzer);
|
||||
const skript = `set -eu
|
||||
B=${shellQuote(benutzer)}
|
||||
getent passwd "$B" >/dev/null || { echo ${MARKE_FEHLT}; exit 3; }
|
||||
H=$(getent passwd "$B" | cut -d: -f6)
|
||||
D="$H/.ssh/authorized_keys"
|
||||
if runuser -u "$B" -- test -e "$D"; then echo "$H ja"; runuser -u "$B" -- cat "$D"; else echo "$H nein"; fi`;
|
||||
const ergebnis = await befehlAusfuehren(
|
||||
verbindung,
|
||||
`${praefix(host)}sh -c ${shellQuote(skript)}`,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
const roh = ergebnis.rohStdout ?? Buffer.alloc(0);
|
||||
if (roh.toString("utf8").startsWith(MARKE_FEHLT)) {
|
||||
throw new VorgangsFehler(`Den Benutzer „${benutzer}“ gibt es auf ${host.name} nicht.`);
|
||||
}
|
||||
if (ergebnis.code !== 0) {
|
||||
throw new VorgangsFehler(`authorized_keys von ${benutzer}@${host.name} ließ sich nicht lesen.`);
|
||||
}
|
||||
const zeilenende = roh.indexOf(0x0a);
|
||||
const kopf = roh.subarray(0, zeilenende).toString("utf8");
|
||||
const vorhanden = kopf.endsWith(" ja");
|
||||
const home = kopf.replace(/ (ja|nein)$/, "");
|
||||
const inhalt = roh.subarray(zeilenende + 1);
|
||||
// Nur gueltiges UTF-8 wird bearbeitet: sonst gingen fremde Bytes beim Zurueckschreiben
|
||||
// verloren, und „bytegenau“ waere gebrochen.
|
||||
if (!Buffer.from(inhalt.toString("utf8"), "utf8").equals(inhalt)) {
|
||||
throw new VorgangsFehler(
|
||||
`authorized_keys von ${benutzer}@${host.name} enthält ungültiges UTF-8. Bitte von Hand prüfen.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
pfad: `${home}/.ssh/authorized_keys`,
|
||||
vorhanden,
|
||||
sha256: inhaltHash(vorhanden ? inhalt : null),
|
||||
inhalt: inhalt.toString("utf8"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt die Datei, wenn ihr Hash auf dem Host noch `erwartet` ist: Sicherung, Temp-Datei,
|
||||
* Rechte, `mv`. Alte Sicherungen ueber SICHERUNGEN_BEHALTEN hinaus werden entfernt.
|
||||
*/
|
||||
export async function authorizedKeysSchreiben(
|
||||
verbindung: Client,
|
||||
host: Host,
|
||||
benutzer: string,
|
||||
inhalt: string,
|
||||
erwartet: string,
|
||||
): Promise<{ sicherung: boolean }> {
|
||||
benutzerPruefen(benutzer);
|
||||
if (!/^([0-9a-f]{64}|keine-datei)$/.test(erwartet)) throw new VorgangsFehler("Ungültiger Datei-Hash.");
|
||||
const skript = `set -eu
|
||||
umask 077
|
||||
B=${shellQuote(benutzer)}
|
||||
ERWARTET=${shellQuote(erwartet)}
|
||||
getent passwd "$B" >/dev/null || { echo ${MARKE_FEHLT}; exit 3; }
|
||||
H=$(getent passwd "$B" | cut -d: -f6)
|
||||
als() { runuser -u "$B" -- "$@"; }
|
||||
als mkdir -p "$H/.ssh"
|
||||
als chmod 0700 "$H/.ssh"
|
||||
D="$H/.ssh/authorized_keys"
|
||||
if als test -e "$D"; then IST=$(als cat "$D" | sha256sum | cut -d' ' -f1); else IST=${KEINE_DATEI}; fi
|
||||
[ "$IST" = "$ERWARTET" ] || { echo ${MARKE_GEAENDERT}; exit 4; }
|
||||
if als test -e "$D"; then
|
||||
als cp -p "$D" "$D.sshkv.bak.$(date +%Y%m%d-%H%M%S)"
|
||||
als sh -c 'ls -1t "$1".sshkv.bak.* 2>/dev/null | tail -n +${SICHERUNGEN_BEHALTEN + 1} | while read -r alt; do rm -f -- "$alt"; done' sh "$D"
|
||||
fi
|
||||
N=$(als mktemp "$H/.ssh/.sshkv.tmp.XXXXXX")
|
||||
als tee "$N" >/dev/null
|
||||
als chmod 0600 "$N"
|
||||
als mv "$N" "$D"
|
||||
echo ${MARKE_OK}`;
|
||||
const ergebnis = await befehlAusfuehren(verbindung, `${praefix(host)}sh -c ${shellQuote(skript)}`, inhalt);
|
||||
if (ergebnis.stdout.includes(MARKE_GEAENDERT)) {
|
||||
throw new VorgangsFehler(
|
||||
`authorized_keys von ${benutzer}@${host.name} wurde seit dem Trockenlauf geändert. Bitte neuen Trockenlauf starten.`,
|
||||
);
|
||||
}
|
||||
if (ergebnis.stdout.includes(MARKE_FEHLT)) {
|
||||
throw new VorgangsFehler(`Den Benutzer „${benutzer}“ gibt es auf ${host.name} nicht.`);
|
||||
}
|
||||
if (ergebnis.code !== 0 || !ergebnis.stdout.includes(MARKE_OK)) {
|
||||
throw new VorgangsFehler(`authorized_keys von ${benutzer}@${host.name} ließ sich nicht schreiben.`);
|
||||
}
|
||||
return { sicherung: erwartet !== KEINE_DATEI };
|
||||
}
|
||||
@@ -189,7 +189,13 @@ function verbindungsFehler(fehler: unknown, ziel: Ziel): VorgangsFehler {
|
||||
return new VorgangsFehler(`SSH-Verbindung zu ${ziel.adresse} fehlgeschlagen.`, { cause: fehler });
|
||||
}
|
||||
|
||||
export type Befehlsergebnis = { readonly stdout: string; readonly stderr: string; readonly code: number };
|
||||
export type Befehlsergebnis = {
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
readonly code: number;
|
||||
/** Nur mit `roh = true`: stdout als Bytes, fuer Dateien, die exakt ankommen muessen. */
|
||||
readonly rohStdout?: Buffer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fuehrt einen Befehl aus. `stdin` wird geschrieben und geschlossen – so reisen Dateiinhalte
|
||||
@@ -199,6 +205,7 @@ export function befehlAusfuehren(
|
||||
client: Client,
|
||||
befehl: string,
|
||||
stdin?: string | Buffer,
|
||||
roh = false,
|
||||
): Promise<Befehlsergebnis> {
|
||||
return new Promise((erfuellen, ablehnen) => {
|
||||
client.exec(befehl, (fehler, strom) => {
|
||||
@@ -227,8 +234,10 @@ export function befehlAusfuehren(
|
||||
if (laenge > AUSGABE_HOECHSTENS) {
|
||||
return ablehnen(new VorgangsFehler("Der Host lieferte mehr Ausgabe als erwartet."));
|
||||
}
|
||||
const stdout = Buffer.concat(ausgabe);
|
||||
erfuellen({
|
||||
stdout: Buffer.concat(ausgabe).toString("utf8"),
|
||||
...(roh ? { rohStdout: stdout } : {}),
|
||||
stdout: stdout.toString("utf8"),
|
||||
stderr: Buffer.concat(fehlerAusgabe).toString("utf8"),
|
||||
code: code ?? -1,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { generateKeyPairSync, randomUUID } from "node:crypto";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import pg from "pg";
|
||||
import "dotenv/config";
|
||||
import { fingerprintSha256 } from "../../src/lib/ssh/openssh";
|
||||
|
||||
/**
|
||||
* Public Key ausrollen ueber die Hostseite (#016), gebauter Stand + Worker, Testziel B.
|
||||
* Schluessel und Zuweisung legt der Test direkt in der Entwicklungsdatenbank an (Praefix zz-),
|
||||
* damit er nur den Ausroll-Weg prueft.
|
||||
*/
|
||||
const ZIEL_PORT = (process.env["SSHKV_TEST_ZIEL_B"] ?? "127.0.0.1:2222").split(":")[1] ?? "2222";
|
||||
const ROOT_PASSWORT = process.env["SSHKV_TEST_ROOT_PASSWORT"] || "sshkv-test";
|
||||
|
||||
test.setTimeout(150_000);
|
||||
|
||||
function aufZielB(befehl: string): string {
|
||||
return execFileSync(
|
||||
"docker",
|
||||
["compose", "-f", "docker-compose.test.yml", "exec", "-T", "ziel-b", "sh", "-c", befehl],
|
||||
{
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function publicKey(kommentar: string) {
|
||||
const { publicKey: schluessel } = generateKeyPairSync("ed25519");
|
||||
const roh = schluessel.export({ format: "der", type: "spki" }).subarray(-32);
|
||||
const typ = Buffer.from("ssh-ed25519");
|
||||
const blob = Buffer.concat([Buffer.from([0, 0, 0, typ.length]), typ, Buffer.from([0, 0, 0, 32]), roh]);
|
||||
return {
|
||||
zeile: `ssh-ed25519 ${blob.toString("base64")} ${kommentar}`,
|
||||
fingerprint: fingerprintSha256(blob),
|
||||
};
|
||||
}
|
||||
|
||||
async function anmelden(page: Page) {
|
||||
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();
|
||||
}
|
||||
|
||||
test.beforeAll(() => {
|
||||
aufZielB("userdel -r sshkv 2>/dev/null; rm -f /etc/sudoers.d/sshkv /root/.ssh/authorized_keys*; true");
|
||||
});
|
||||
|
||||
test("Public Key ueber die Hostseite ausrollen", async ({ page }) => {
|
||||
const name = `zz-e2e-${Date.now().toString(36)}`;
|
||||
await anmelden(page);
|
||||
|
||||
// Host einrichten wie in host-einrichten.spec.ts.
|
||||
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(ZIEL_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 });
|
||||
const hostId = page.url().split("/").pop()!;
|
||||
|
||||
// Schluessel und Zuweisung direkt anlegen.
|
||||
const key = publicKey(`sshkv:${name}`);
|
||||
const client = new pg.Client({ connectionString: process.env["DATABASE_URL"] });
|
||||
await client.connect();
|
||||
try {
|
||||
const schluesselId = randomUUID();
|
||||
await client.query(
|
||||
`INSERT INTO "Schluessel" ("id","name","slug","typ","kategorie","publicKey","fingerprint","kommentar","geaendertAm")
|
||||
VALUES ($1,$2,$3,'ED25519','DIENST',$4,$5,$6,now())`,
|
||||
[schluesselId, `${name} Backup`, name, key.zeile, key.fingerprint, `sshkv:${name}`],
|
||||
);
|
||||
await client.query(
|
||||
`INSERT INTO "PublicVerwendung" ("id","schluesselId","hostId","zielbenutzer","optionen") VALUES ($1,$2,$3,'root',$4)`,
|
||||
[randomUUID(), schluesselId, hostId, 'restrict,from="10.0.0.12"'],
|
||||
);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByText("1 Zuweisung(en) noch nicht ausgerollt.")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Dry-Run starten" }).click();
|
||||
await expect(page.getByText(`root@${name}:/root/.ssh/authorized_keys`)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText(`restrict,from="10.0.0.12" ${key.zeile}`)).toBeVisible();
|
||||
expect(aufZielB("cat /root/.ssh/authorized_keys 2>/dev/null || true")).not.toContain(key.fingerprint);
|
||||
await page.screenshot({ path: "docs/anleitung/bilder/dry-run-desktop.png", fullPage: true });
|
||||
|
||||
await page.getByRole("button", { name: /Änderung anwenden/ }).click();
|
||||
await expect(page.getByText("Ausgerollt.")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("synchron").first()).toBeVisible();
|
||||
expect(aufZielB("cat /root/.ssh/authorized_keys")).toContain(`restrict,from="10.0.0.12" ${key.zeile}`);
|
||||
|
||||
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,195 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { schluesselpaarErzeugen } from "@/lib/ssh/openssh";
|
||||
import { publicVerwendungAnlegen, publicVerwendungEntfernen } from "@/server/services/verwendungen";
|
||||
import { deploymentKeySicherstellen } from "@/worker/geheimnisse";
|
||||
import type { Plan } from "@/worker/ablaeufe/ausrollen";
|
||||
import { datenbankLeeren } from "../hilfen/db";
|
||||
import { eingerichteterHost, masterKeyAnlegen, vorgangLaufen } from "./hilfen/host";
|
||||
import { befehlAlsRoot, zielA, zielBereit } from "./hilfen/ziele";
|
||||
|
||||
/** Public Keys ausrollen gegen ein echtes sshd (#016). */
|
||||
|
||||
const AKTEUR = { id: "test", name: "Test" };
|
||||
let aufraeumen: () => void;
|
||||
|
||||
beforeAll(async () => {
|
||||
await zielBereit(zielA);
|
||||
aufraeumen = masterKeyAnlegen();
|
||||
});
|
||||
afterAll(() => aufraeumen());
|
||||
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
await deploymentKeySicherstellen();
|
||||
await befehlAlsRoot(
|
||||
zielA,
|
||||
"userdel -r sshkv 2>/dev/null; rm -f /etc/sudoers.d/sshkv /root/.ssh/authorized_keys* /home/test/.ssh/authorized_keys*; true",
|
||||
);
|
||||
});
|
||||
|
||||
async function schluesselAnlegen(name: string) {
|
||||
const paar = await schluesselpaarErzeugen("ED25519", { kommentar: `sshkv:${name}` });
|
||||
return prisma.schluessel.create({
|
||||
data: {
|
||||
name,
|
||||
slug: name,
|
||||
typ: "ED25519",
|
||||
kategorie: "DIENST",
|
||||
publicKey: paar.publicKeyZeile,
|
||||
fingerprint: paar.fingerprint,
|
||||
kommentar: `sshkv:${name}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function trockenlauf(schluesselId: string) {
|
||||
const vorgang = await vorgangLaufen("TROCKENLAUF", {
|
||||
eingabe: {
|
||||
ziele: (
|
||||
await prisma.publicVerwendung.findMany({
|
||||
where: { schluesselId },
|
||||
include: { hostgruppe: { include: { mitglieder: true } } },
|
||||
})
|
||||
).flatMap((v) =>
|
||||
v.hostId
|
||||
? [{ hostId: v.hostId, zielbenutzer: v.zielbenutzer }]
|
||||
: (v.hostgruppe?.mitglieder.map((m) => ({ hostId: m.hostId, zielbenutzer: v.zielbenutzer })) ?? []),
|
||||
),
|
||||
},
|
||||
});
|
||||
expect(vorgang.status).toBe("ERLEDIGT");
|
||||
return { id: vorgang.id, plan: vorgang.plan as unknown as Plan };
|
||||
}
|
||||
|
||||
const datei = async (pfad: string) => (await befehlAlsRoot(zielA, `cat ${pfad} 2>/dev/null || true`)).stdout;
|
||||
|
||||
describe("Public Key ausrollen", () => {
|
||||
it("zeigt im Trockenlauf die Aenderung, schreibt erst beim Ausrollen", async () => {
|
||||
const host = await eingerichteterHost(zielA, "ziel-a");
|
||||
await befehlAlsRoot(zielA, "mkdir -p /root/.ssh && printf '# von Hand\\n' > /root/.ssh/authorized_keys");
|
||||
const schluessel = await schluesselAnlegen("backup");
|
||||
const verwendungId = await publicVerwendungAnlegen(
|
||||
{
|
||||
schluesselId: schluessel.id,
|
||||
hostId: host.id,
|
||||
zielbenutzer: "root",
|
||||
optionen: 'restrict,from="10.0.0.12"',
|
||||
},
|
||||
AKTEUR,
|
||||
);
|
||||
|
||||
const { id, plan } = await trockenlauf(schluessel.id);
|
||||
expect(plan.ziele).toHaveLength(1);
|
||||
expect(plan.ziele[0]).toMatchObject({
|
||||
zielbenutzer: "root",
|
||||
geaendert: true,
|
||||
pfad: "/root/.ssh/authorized_keys",
|
||||
});
|
||||
expect(plan.ziele[0]!.diff).toContainEqual({
|
||||
art: "hinzu",
|
||||
text: `restrict,from="10.0.0.12" ${schluessel.publicKey}`,
|
||||
});
|
||||
expect(await datei("/root/.ssh/authorized_keys")).toBe("# von Hand\n");
|
||||
|
||||
const ausrollen = await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: id } });
|
||||
expect(ausrollen.fehler).toBeNull();
|
||||
const inhalt = await datei("/root/.ssh/authorized_keys");
|
||||
expect(inhalt).toBe(
|
||||
`# von Hand\n# BEGIN SSHKV MANAGED – nicht manuell bearbeiten\nrestrict,from="10.0.0.12" ${schluessel.publicKey}\n# END SSHKV MANAGED\n`,
|
||||
);
|
||||
expect((await prisma.publicVerwendung.findUniqueOrThrow({ where: { id: verwendungId } })).zustand).toBe(
|
||||
"AUSGEROLLT",
|
||||
);
|
||||
expect((await befehlAlsRoot(zielA, "ls /root/.ssh | grep -c sshkv.bak")).stdout.trim()).toBe("1");
|
||||
expect((await befehlAlsRoot(zielA, "stat -c %a /root/.ssh/authorized_keys")).stdout.trim()).toBe("600");
|
||||
const eintrag = await prisma.protokoll.findFirst({ where: { aktion: "authorized_keys.geschrieben" } });
|
||||
expect(eintrag?.diff).toContain("+ restrict");
|
||||
|
||||
// Derselbe Trockenlauf nicht zweimal.
|
||||
const nochmal = await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: id } });
|
||||
expect(nochmal.fehler).toMatch(/bereits ausgeführt/);
|
||||
});
|
||||
|
||||
it("bricht ab, wenn die Datei seit dem Trockenlauf geaendert wurde", async () => {
|
||||
const host = await eingerichteterHost(zielA, "ziel-a");
|
||||
const schluessel = await schluesselAnlegen("backup");
|
||||
await publicVerwendungAnlegen(
|
||||
{ schluesselId: schluessel.id, hostId: host.id, zielbenutzer: "root" },
|
||||
AKTEUR,
|
||||
);
|
||||
const { id } = await trockenlauf(schluessel.id);
|
||||
await befehlAlsRoot(zielA, "mkdir -p /root/.ssh && echo '# dazwischen' >> /root/.ssh/authorized_keys");
|
||||
const ausrollen = await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: id } });
|
||||
expect(ausrollen.status).toBe("FEHLGESCHLAGEN");
|
||||
expect(ausrollen.fehler).toMatch(/seit dem Trockenlauf geändert/);
|
||||
expect(await datei("/root/.ssh/authorized_keys")).toBe("# dazwischen\n");
|
||||
});
|
||||
|
||||
it("bricht ab, wenn sich die Zuweisungen seit dem Trockenlauf geaendert haben", async () => {
|
||||
const host = await eingerichteterHost(zielA, "ziel-a");
|
||||
const a = await schluesselAnlegen("a");
|
||||
const b = await schluesselAnlegen("b");
|
||||
await publicVerwendungAnlegen({ schluesselId: a.id, hostId: host.id, zielbenutzer: "root" }, AKTEUR);
|
||||
const { id } = await trockenlauf(a.id);
|
||||
await publicVerwendungAnlegen({ schluesselId: b.id, hostId: host.id, zielbenutzer: "root" }, AKTEUR);
|
||||
const ausrollen = await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: id } });
|
||||
expect(ausrollen.fehler).toMatch(/Zuweisungen .* geändert/);
|
||||
});
|
||||
|
||||
it("entfernt einen Schluessel wieder, und der Deployment-Key bleibt", async () => {
|
||||
const host = await eingerichteterHost(zielA, "ziel-a");
|
||||
const schluessel = await schluesselAnlegen("weg");
|
||||
const id = await publicVerwendungAnlegen(
|
||||
{ schluesselId: schluessel.id, hostId: host.id, zielbenutzer: "sshkv" },
|
||||
AKTEUR,
|
||||
);
|
||||
const erster = await trockenlauf(schluessel.id);
|
||||
// Im Block des Dienstbenutzers steht der Deployment-Key schon; nur der neue kommt hinzu.
|
||||
expect(erster.plan.ziele[0]!.diff.filter((z) => z.art !== "kontext")).toEqual([
|
||||
{ art: "hinzu", text: schluessel.publicKey },
|
||||
]);
|
||||
await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: erster.id } });
|
||||
expect(await datei("/home/sshkv/.ssh/authorized_keys")).toContain(schluessel.publicKey);
|
||||
|
||||
await publicVerwendungEntfernen(id, AKTEUR);
|
||||
const zweiter = await trockenlauf(schluessel.id);
|
||||
expect(zweiter.plan.ziele[0]!.diff).toContainEqual({ art: "entfernt", text: schluessel.publicKey });
|
||||
await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: zweiter.id } });
|
||||
const inhalt = await datei("/home/sshkv/.ssh/authorized_keys");
|
||||
expect(inhalt).not.toContain(schluessel.publicKey);
|
||||
expect(inhalt).toContain("sshkv-deployment@");
|
||||
expect((await prisma.publicVerwendung.findUniqueOrThrow({ where: { id } })).zustand).toBe("ENTFERNT");
|
||||
});
|
||||
|
||||
it("rollt ueber eine Hostgruppe fuer einen gewoehnlichen Benutzer aus", async () => {
|
||||
const host = await eingerichteterHost(zielA, "ziel-a");
|
||||
const gruppe = await prisma.hostgruppe.create({
|
||||
data: { name: "Test", mitglieder: { create: { hostId: host.id } } },
|
||||
});
|
||||
const schluessel = await schluesselAnlegen("gruppe");
|
||||
await publicVerwendungAnlegen(
|
||||
{ schluesselId: schluessel.id, hostgruppeId: gruppe.id, zielbenutzer: "test" },
|
||||
AKTEUR,
|
||||
);
|
||||
const { id } = await trockenlauf(schluessel.id);
|
||||
await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: id } });
|
||||
expect(await datei("/home/test/.ssh/authorized_keys")).toContain(schluessel.publicKey);
|
||||
const besitz = await befehlAlsRoot(
|
||||
zielA,
|
||||
"stat -c '%U %a' /home/test/.ssh /home/test/.ssh/authorized_keys",
|
||||
);
|
||||
expect(besitz.stdout).toBe("test 700\ntest 600\n");
|
||||
});
|
||||
|
||||
it("meldet einen fehlenden Benutzer im Trockenlauf je Ziel", async () => {
|
||||
const host = await eingerichteterHost(zielA, "ziel-a");
|
||||
const schluessel = await schluesselAnlegen("niemand");
|
||||
await publicVerwendungAnlegen(
|
||||
{ schluesselId: schluessel.id, hostId: host.id, zielbenutzer: "gibtsnicht" },
|
||||
AKTEUR,
|
||||
);
|
||||
const { plan } = await trockenlauf(schluessel.id);
|
||||
expect(plan.ziele[0]!.fehler).toMatch(/gibt es auf ziel-a nicht/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { verschluesseln } from "@/lib/geheimnisse";
|
||||
import { vorgangAusfuehren } from "@/worker/ausfuehren";
|
||||
import { masterKeyVergessen } from "@/worker/geheimnisse";
|
||||
import { HANDLER } from "@/worker/handler";
|
||||
import { rootPasswort, type Ziel } from "./ziele";
|
||||
|
||||
/** Gemeinsame Vorbereitung fuer Integrationstests: Master-Key, Host mit Bootstrap (#016). */
|
||||
|
||||
export function masterKeyAnlegen(): () => void {
|
||||
const verzeichnis = mkdtempSync(path.join(tmpdir(), "sshkv-master-"));
|
||||
const datei = path.join(verzeichnis, "master.key");
|
||||
writeFileSync(datei, randomBytes(32), { mode: 0o600 });
|
||||
process.env["SSHKV_MASTER_KEY_FILE"] = datei;
|
||||
masterKeyVergessen();
|
||||
return () => rmSync(verzeichnis, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
export async function vorgangLaufen(
|
||||
art: "HOSTKEY_ABRUFEN" | "BOOTSTRAP" | "TROCKENLAUF" | "AUSROLLEN",
|
||||
daten: {
|
||||
hostId?: string;
|
||||
eingabe?: object;
|
||||
geheim?: string;
|
||||
angefordertVonId?: string;
|
||||
},
|
||||
) {
|
||||
const vorgang = await prisma.vorgang.create({
|
||||
data: {
|
||||
art,
|
||||
hostId: daten.hostId ?? null,
|
||||
eingabe: daten.eingabe ?? {},
|
||||
angefordertVonId: daten.angefordertVonId ?? null,
|
||||
},
|
||||
});
|
||||
await vorgangAusfuehren(vorgang.id, HANDLER, daten.geheim);
|
||||
return prisma.vorgang.findUniqueOrThrow({ where: { id: vorgang.id } });
|
||||
}
|
||||
|
||||
/** Host anlegen, Host-Key vertrauen, Bootstrap per root-Passwort. */
|
||||
export async function eingerichteterHost(
|
||||
ziel: Ziel,
|
||||
name: string,
|
||||
anmeldeart: "DIENSTBENUTZER" | "ROOT" = "DIENSTBENUTZER",
|
||||
) {
|
||||
const host = await prisma.host.create({ data: { name, adresse: ziel.host, port: ziel.port, anmeldeart } });
|
||||
await vorgangLaufen("HOSTKEY_ABRUFEN", { hostId: host.id });
|
||||
await prisma.hostSchluessel.updateMany({ where: { hostId: host.id }, data: { status: "VERTRAUT" } });
|
||||
const bootstrap = await vorgangLaufen("BOOTSTRAP", {
|
||||
hostId: host.id,
|
||||
eingabe: { modus: "passwort", benutzer: "root" },
|
||||
geheim: verschluesseln(rootPasswort()),
|
||||
});
|
||||
if (bootstrap.status !== "ERLEDIGT") throw new Error(`Bootstrap fehlgeschlagen: ${bootstrap.fehler}`);
|
||||
return prisma.host.findUniqueOrThrow({ where: { id: host.id } });
|
||||
}
|
||||
Reference in New Issue
Block a user