Merge branch 'phase/016-public-key-ausrollen'

This commit is contained in:
2026-10-12 10:36:22 +02:00
17 changed files with 1586 additions and 9 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

+61 -3
View File
@@ -18,6 +18,9 @@ import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { hostDetail, type HostDetail } from "@/server/queries/hosts"; import { hostDetail, type HostDetail } from "@/server/queries/hosts";
import { BootstrapAktionen } from "./BootstrapAktionen"; import { BootstrapAktionen } from "./BootstrapAktionen";
import { sshdHinweise } from "@/lib/sshd-hinweise"; 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 { HostKeyAktionen } from "./HostKeyAktionen";
import { HostKeyEntscheidung } from "./HostKeyEntscheidung"; import { HostKeyEntscheidung } from "./HostKeyEntscheidung";
import { bootstrapBefehlFuer } from "@/server/queries/bootstrap"; 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 archiviert = host.archiviertAm !== null;
const verwalten = darf(benutzer.rolle, "hostsVerwalten") && !archiviert; const verwalten = darf(benutzer.rolle, "hostsVerwalten") && !archiviert;
const darfEntscheiden = darf(benutzer.rolle, "hostKeyEntscheiden") && !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(); const jetzt = new Date();
return ( return (
@@ -217,7 +222,7 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri
<Karte <Karte
titel="Schlüssel auf diesem Host" 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 && ( {host.zielkonten.length > 0 && (
<p className="text-muted mb-4 text-[14px]"> <p className="text-muted mb-4 text-[14px]">
@@ -230,11 +235,64 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri
))} ))}
</p> </p>
)} )}
{verwendungen.oeffentlich.length === 0 && verwendungen.privat.length === 0 ? (
<Leerzustand <Leerzustand
icon={<KeyRound className="size-5" />} icon={<KeyRound className="size-5" />}
titel="Folgt mit #016" titel="Noch keine Schlüssel zugewiesen"
text="Hier erscheinen die Public und Private Keys, die sshkv auf diesem Host verwaltet." 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> </Karte>
{verwalten && ( {verwalten && (
+133
View File
@@ -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>
);
}
+53
View File
@@ -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 } : {}),
};
}
+101
View File
@@ -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);
}
}
+55
View File
@@ -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 };
}
+142
View File
@@ -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()];
}
+152
View File
@@ -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 },
});
}
+267
View File
@@ -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 } };
};
+4 -1
View File
@@ -1,12 +1,15 @@
import type { HandlerTabelle } from "@/worker/ausfuehren"; import type { HandlerTabelle } from "@/worker/ausfuehren";
import { ausrollenHandler, trockenlaufHandler } from "@/worker/ablaeufe/ausrollen";
import { bootstrapHandler } from "@/worker/ablaeufe/bootstrap"; import { bootstrapHandler } from "@/worker/ablaeufe/bootstrap";
import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey"; 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 = { export const HANDLER: HandlerTabelle = {
HOSTKEY_ABRUFEN: hostKeyAbrufenHandler, HOSTKEY_ABRUFEN: hostKeyAbrufenHandler,
ERREICHBARKEIT: erreichbarkeitHandler, ERREICHBARKEIT: erreichbarkeitHandler,
BOOTSTRAP: bootstrapHandler, BOOTSTRAP: bootstrapHandler,
TROCKENLAUF: trockenlaufHandler,
AUSROLLEN: ausrollenHandler,
}; };
+141
View File
@@ -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 };
}
+11 -2
View File
@@ -189,7 +189,13 @@ function verbindungsFehler(fehler: unknown, ziel: Ziel): VorgangsFehler {
return new VorgangsFehler(`SSH-Verbindung zu ${ziel.adresse} fehlgeschlagen.`, { cause: fehler }); 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 * Fuehrt einen Befehl aus. `stdin` wird geschrieben und geschlossen – so reisen Dateiinhalte
@@ -199,6 +205,7 @@ export function befehlAusfuehren(
client: Client, client: Client,
befehl: string, befehl: string,
stdin?: string | Buffer, stdin?: string | Buffer,
roh = false,
): Promise<Befehlsergebnis> { ): Promise<Befehlsergebnis> {
return new Promise((erfuellen, ablehnen) => { return new Promise((erfuellen, ablehnen) => {
client.exec(befehl, (fehler, strom) => { client.exec(befehl, (fehler, strom) => {
@@ -227,8 +234,10 @@ export function befehlAusfuehren(
if (laenge > AUSGABE_HOECHSTENS) { if (laenge > AUSGABE_HOECHSTENS) {
return ablehnen(new VorgangsFehler("Der Host lieferte mehr Ausgabe als erwartet.")); return ablehnen(new VorgangsFehler("Der Host lieferte mehr Ausgabe als erwartet."));
} }
const stdout = Buffer.concat(ausgabe);
erfuellen({ erfuellen({
stdout: Buffer.concat(ausgabe).toString("utf8"), ...(roh ? { rohStdout: stdout } : {}),
stdout: stdout.toString("utf8"),
stderr: Buffer.concat(fehlerAusgabe).toString("utf8"), stderr: Buffer.concat(fehlerAusgabe).toString("utf8"),
code: code ?? -1, code: code ?? -1,
}); });
+103
View File
@@ -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$/);
});
+195
View File
@@ -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/);
});
});
+60
View File
@@ -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 } });
}