Ein Durchgang fuer den Kern-Anwendungsfall: Vorlage und Zweck, Host A, Host B mit Zielbenutzer, Zusammenfassung, dann Schluessel erzeugen, Benutzer anlegen, zuweisen, Dry-Run, ausrollen und Verbindung testen. Die Vorlage Deployment-Zugang fuellt deploy, Ablaufdatum, from= und Alias vor; bei root als Ziel warnt der Assistent ausdruecklich. Die Ausfuehrung ist wiederaufnehmbar: wird mittendrin eine frische Bestaetigung fuer das Zielkonto noetig, geschieht sie in einem neuen Tab, und nichts wird doppelt angelegt. Zielkonten laufen als eigene Vorgaenge (neue Migration) und merken sich, ob sshkv den Benutzer angelegt hat - nur dann loescht ein Widerruf ihn spaeter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
"use server";
|
||||
|
||||
import { ZielkontoFehler } from "@/lib/ssh/zielkonto";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { akteurVon } from "@/server/actions/formular";
|
||||
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { HostFehler } from "@/server/services/hosts";
|
||||
import { erzeugenAnfordern } from "@/server/services/schluessel";
|
||||
import {
|
||||
VerwendungsFehler,
|
||||
privateVerwendungAnlegen,
|
||||
publicVerwendungAnlegen,
|
||||
} from "@/server/services/verwendungen";
|
||||
import { zielkontoAnfordern } from "@/server/services/zielkonten";
|
||||
|
||||
/**
|
||||
* Schritte des Host-zu-Host-Assistenten (#020). Jeder Schritt ist eine eigene Aktion, die
|
||||
* Oberflaeche verkettet sie und wartet dazwischen auf den Worker. So bleibt jede Aktion
|
||||
* duenn, und jeder Schritt hat seine eigene Rechtepruefung.
|
||||
*/
|
||||
export type SchrittAntwort = {
|
||||
readonly vorgangId?: string;
|
||||
readonly privatVerwendungId?: string;
|
||||
readonly fehler?: string;
|
||||
/** Kritischer Schritt ohne frische Bestaetigung: dorthin, dann weiter. */
|
||||
readonly bestaetigen?: boolean;
|
||||
};
|
||||
|
||||
function alsAntwort(fehler: unknown): SchrittAntwort {
|
||||
if (fehler instanceof BestaetigungFehler) return { bestaetigen: true, fehler: fehler.message };
|
||||
if (
|
||||
fehler instanceof RechteFehler ||
|
||||
fehler instanceof VerwendungsFehler ||
|
||||
fehler instanceof ZielkontoFehler ||
|
||||
fehler instanceof HostFehler
|
||||
) {
|
||||
return { fehler: fehler.message };
|
||||
}
|
||||
throw fehler;
|
||||
}
|
||||
|
||||
const id = z.string().min(1).max(64);
|
||||
|
||||
export async function assistentErzeugenAktion(eingabe: {
|
||||
name: string;
|
||||
vorlageId: string | null;
|
||||
ablaufAm: string | null;
|
||||
}): Promise<SchrittAntwort> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("privateKeyAusrollen");
|
||||
return {
|
||||
vorgangId: await erzeugenAnfordern(
|
||||
{
|
||||
name: eingabe.name,
|
||||
vorlageId: eingabe.vorlageId,
|
||||
ablaufAm: eingabe.ablaufAm,
|
||||
tags: ["host-zu-host"],
|
||||
},
|
||||
undefined,
|
||||
akteurVon(benutzer),
|
||||
),
|
||||
};
|
||||
} catch (fehler) {
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function assistentZielkontoAktion(eingabe: {
|
||||
hostId: string;
|
||||
benutzer: string;
|
||||
modus: "KEINER" | "BEFEHLE" | "VOLL";
|
||||
befehle: string[];
|
||||
}): Promise<SchrittAntwort> {
|
||||
try {
|
||||
// Ein neuer Benutzer mit sudo ist so viel wert wie root: Admin mit frischer Bestaetigung.
|
||||
const { benutzer } = await rechtErforderlich("zielkontenVerwalten");
|
||||
return {
|
||||
vorgangId: await zielkontoAnfordern({ ...eingabe, hostId: id.parse(eingabe.hostId) }, benutzer),
|
||||
};
|
||||
} catch (fehler) {
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function assistentZuweisenAktion(eingabe: {
|
||||
schluesselId: string;
|
||||
quelle: { hostId: string; benutzer: string; pfad: string };
|
||||
ziel: { hostId: string; benutzer: string; optionen: string | null };
|
||||
ssh: { alias: string; hostName: string; port: number; knownHosts: boolean };
|
||||
}): Promise<SchrittAntwort> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("privateKeyAusrollen");
|
||||
const akteur = akteurVon(benutzer);
|
||||
const privatVerwendungId = await privateVerwendungAnlegen(
|
||||
{
|
||||
schluesselId: id.parse(eingabe.schluesselId),
|
||||
hostId: id.parse(eingabe.quelle.hostId),
|
||||
zielbenutzer: eingabe.quelle.benutzer,
|
||||
pfad: eingabe.quelle.pfad,
|
||||
sshConfig: {
|
||||
alias: eingabe.ssh.alias,
|
||||
hostName: eingabe.ssh.hostName,
|
||||
benutzer: eingabe.ziel.benutzer,
|
||||
port: eingabe.ssh.port,
|
||||
},
|
||||
knownHostsFuer: eingabe.ssh.knownHosts ? [id.parse(eingabe.ziel.hostId)] : [],
|
||||
},
|
||||
akteur,
|
||||
);
|
||||
await publicVerwendungAnlegen(
|
||||
{
|
||||
schluesselId: eingabe.schluesselId,
|
||||
hostId: id.parse(eingabe.ziel.hostId),
|
||||
zielbenutzer: eingabe.ziel.benutzer,
|
||||
optionen: eingabe.ziel.optionen,
|
||||
},
|
||||
akteur,
|
||||
);
|
||||
return { privatVerwendungId };
|
||||
} catch (fehler) {
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/** Was der Host-zu-Host-Assistent zur Auswahl braucht (#020) – nur oeffentliche Angaben. */
|
||||
export async function assistentDaten() {
|
||||
const [hosts, vorlagen] = await Promise.all([
|
||||
prisma.host.findMany({
|
||||
where: { archiviertAm: null },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
adresse: true,
|
||||
port: true,
|
||||
bootstrapAm: true,
|
||||
hostSchluessel: { where: { status: "VERTRAUT" }, select: { algorithmus: true, fingerprint: true } },
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
prisma.vorlage.findMany({
|
||||
where: { archiviertAm: null, kategorie: "DIENST" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
beschreibung: true,
|
||||
standardLaufzeitTage: true,
|
||||
zielbenutzer: true,
|
||||
zielbenutzerAnlegen: true,
|
||||
sudoModus: true,
|
||||
sudoBefehle: true,
|
||||
authorizedKeysOptionen: true,
|
||||
sshConfigAlias: true,
|
||||
knownHostsEintragen: true,
|
||||
},
|
||||
orderBy: [{ eingebaut: "desc" }, { name: "asc" }],
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
hosts: hosts
|
||||
.filter((h) => h.bootstrapAm !== null)
|
||||
.map((h) => ({ id: h.id, name: h.name, adresse: h.adresse, port: h.port, hostKeys: h.hostSchluessel })),
|
||||
vorlagen,
|
||||
};
|
||||
}
|
||||
|
||||
export type AssistentDaten = Awaited<ReturnType<typeof assistentDaten>>;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { BENUTZERNAME_MUSTER } from "@/lib/shell";
|
||||
import { istGeschuetzt, sudoersInhalt, ZielkontoFehler, type SudoModus } from "@/lib/ssh/zielkonto";
|
||||
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||
|
||||
/**
|
||||
* Zielkonten anfordern (#018). Nur Admins mit frischer Bestaetigung (Recht
|
||||
* zielkontenVerwalten) – ein neuer Benutzer mit sudo ist so viel wert wie root.
|
||||
*/
|
||||
export function zielkontoEingabePruefen(
|
||||
benutzer: string,
|
||||
modus: SudoModus,
|
||||
befehle: readonly string[],
|
||||
): void {
|
||||
if (!BENUTZERNAME_MUSTER.test(benutzer)) throw new ZielkontoFehler("Ungültiger Benutzername.");
|
||||
if (istGeschuetzt(benutzer))
|
||||
throw new ZielkontoFehler(`„${benutzer}“ verwaltet sshkv nicht als Zielkonto.`);
|
||||
sudoersInhalt(benutzer, modus, befehle);
|
||||
}
|
||||
|
||||
export async function zielkontoAnfordern(
|
||||
eingabe: { hostId: string; benutzer: string; modus: SudoModus; befehle: readonly string[] },
|
||||
akteur: { id: string },
|
||||
): Promise<string> {
|
||||
zielkontoEingabePruefen(eingabe.benutzer, eingabe.modus, eingabe.befehle);
|
||||
return vorgangEinreihen({
|
||||
art: "ZIELKONTO_ANLEGEN",
|
||||
angefordertVonId: akteur.id,
|
||||
hostId: eingabe.hostId,
|
||||
eingabe: { benutzer: eingabe.benutzer, modus: eingabe.modus, befehle: [...eingabe.befehle] },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user