feat(bootstrap): Deployment-Key per Einmal-Passwort oder Befehl (#010)
Ein einziges POSIX-Skript fuer beide Wege: der Worker fuehrt es nach einer einmaligen Passwortanmeldung aus, und genau dasselbe Skript steht als Befehl zum Kopieren bereit. So gibt es keine zwei Wahrheiten darueber, was ein Bootstrap auf dem Host aendert. Das Passwort reist nur verschluesselt im Job und steht nie in Vorgang, Protokoll oder Log. Dateien im Home des Zielbenutzers liest und schreibt das Skript als dieser Benutzer: ein Symlink auf /etc/shadow fuehrt zum Abbruch statt zur Kopie (Integrationstest). sudoers wird erst nach visudo -c aktiv. Am Ende beweist eine Anmeldung per Deployment-Key, dass sshkv ohne Passwort weiterarbeiten kann. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
"use server";
|
||||
|
||||
import { verschluesseln } from "@/lib/geheimnisse";
|
||||
import { BENUTZERNAME_MUSTER } from "@/lib/shell";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||
|
||||
/**
|
||||
* Bootstrap starten (#010). Das Passwort verlaesst diese Funktion nur verschluesselt im Job
|
||||
* und steht nie in `Vorgang.eingabe`, im Protokoll oder in einem Log.
|
||||
*/
|
||||
export type BootstrapAntwort = { readonly vorgangId?: string; readonly fehler?: string };
|
||||
|
||||
const passwortSchema = z.object({
|
||||
hostId: z.string().min(1).max(64),
|
||||
benutzer: z.string().regex(BENUTZERNAME_MUSTER, "Ungültiger Benutzername."),
|
||||
passwort: z.string().min(1).max(1024),
|
||||
});
|
||||
|
||||
export async function bootstrapPerPasswortAktion(
|
||||
eingabe: z.input<typeof passwortSchema>,
|
||||
): Promise<BootstrapAntwort> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("hostsVerwalten");
|
||||
const daten = passwortSchema.safeParse(eingabe);
|
||||
if (!daten.success) return { fehler: daten.error.issues[0]?.message ?? "Eingaben unvollständig." };
|
||||
const vorgangId = await vorgangEinreihen({
|
||||
art: "BOOTSTRAP",
|
||||
angefordertVonId: benutzer.id,
|
||||
hostId: daten.data.hostId,
|
||||
eingabe: { modus: "passwort", benutzer: daten.data.benutzer },
|
||||
geheim: verschluesseln(daten.data.passwort),
|
||||
});
|
||||
await protokollieren({
|
||||
akteur: { id: benutzer.id, name: benutzer.name },
|
||||
aktion: "host.bootstrap.angefordert",
|
||||
ergebnis: "ERFOLG",
|
||||
details: { hostId: daten.data.hostId, anmeldung: daten.data.benutzer, art: "passwort" },
|
||||
});
|
||||
return { vorgangId };
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof RechteFehler) return { fehler: fehler.message };
|
||||
throw fehler;
|
||||
}
|
||||
}
|
||||
|
||||
export async function bootstrapPruefenAktion(hostId: string): Promise<BootstrapAntwort> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("hostsVerwalten");
|
||||
const id = z.string().min(1).max(64).parse(hostId);
|
||||
return {
|
||||
vorgangId: await vorgangEinreihen({
|
||||
art: "BOOTSTRAP",
|
||||
angefordertVonId: benutzer.id,
|
||||
hostId: id,
|
||||
eingabe: { modus: "pruefen" },
|
||||
}),
|
||||
};
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof RechteFehler) return { fehler: fehler.message };
|
||||
throw fehler;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { bootstrapBefehl } from "@/lib/ssh/bootstrap-skript";
|
||||
|
||||
/**
|
||||
* Der Befehl zum Kopieren (#010). Er enthaelt nur den Public Key des Deployment-Keys und
|
||||
* darf deshalb in die Oberflaeche.
|
||||
*/
|
||||
export async function bootstrapBefehlFuer(hostId: string): Promise<string | undefined> {
|
||||
const host = await prisma.host.findUnique({
|
||||
where: { id: hostId },
|
||||
select: {
|
||||
anmeldeart: true,
|
||||
deploymentSchluessel: { select: { publicKey: true } },
|
||||
gruppen: {
|
||||
select: { hostgruppe: { select: { deploymentSchluessel: { select: { publicKey: true } } } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!host) return undefined;
|
||||
const standard = await prisma.schluessel.findFirst({
|
||||
where: { kategorie: "DEPLOYMENT", instanzStandard: true, status: "AKTIV" },
|
||||
select: { publicKey: true },
|
||||
});
|
||||
// Dieselbe Reihenfolge wie im Worker (deploymentKeyIdFuer): Host, Gruppe, Instanz.
|
||||
const publicKey =
|
||||
host.deploymentSchluessel?.publicKey ??
|
||||
host.gruppen.find((g) => g.hostgruppe.deploymentSchluessel)?.hostgruppe.deploymentSchluessel?.publicKey ??
|
||||
standard?.publicKey;
|
||||
if (!publicKey) return undefined;
|
||||
return bootstrapBefehl({
|
||||
benutzer: host.anmeldeart === "ROOT" ? "root" : "sshkv",
|
||||
dienstbenutzerAnlegen: host.anmeldeart === "DIENSTBENUTZER",
|
||||
deploymentKey: publicKey,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user