Merge branch 'phase/018-020-assistent'
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