feat(ausrollen): Private Key mit ssh-config und known_hosts (#017)
Der Kern-Anwendungsfall im Worker: Private Key auf Host A, Public Key auf Host B, dazu ein Block in ~/.ssh/config von A und der vertraute Host-Key von B in known_hosts - danach meldet sich A per Alias bei B an, ohne Rueckfrage zum Host-Key (Integrationstest gegen zwei echte sshd). Gleiches Muster wie bei Public Keys: Plan je Datei mit Hashes, dann genau dieser Plan. Der Inhalt eines Private Keys steht nie im Plan oder Protokoll, eine fremde Datei am Zielpfad wird nie ueberschrieben, und von einem alten Private Key bleibt keine Sicherungskopie liegen. Der config-Block steht am Anfang, weil ssh den ersten Treffer nimmt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -94,7 +94,12 @@ export async function sollFuer(hostId: string, zielbenutzer: string): Promise<So
|
||||
return { eintraege, pflicht, verwendungIds: { drin, raus } };
|
||||
}
|
||||
|
||||
export type Ziel = { readonly hostId: string; readonly zielbenutzer: string };
|
||||
/** Ein Ziel des Ausrollens: authorized_keys (public) oder ~/.ssh-Dateien eines Quell-Hosts (privat). */
|
||||
export type Ziel = {
|
||||
readonly hostId: string;
|
||||
readonly zielbenutzer: string;
|
||||
readonly art?: "public" | "privat";
|
||||
};
|
||||
|
||||
/** Alle (Host, Benutzer)-Paare, die eine Public-Key-Verwendung betrifft – Gruppen aufgeloest. */
|
||||
export async function zieleVonVerwendungen(where: {
|
||||
@@ -140,3 +145,28 @@ export async function zieleVonVerwendungen(where: {
|
||||
}
|
||||
return [...ziele.values()];
|
||||
}
|
||||
|
||||
/** Ziele der Private-Key-Verwendungen (#017): je Quell-Host und Benutzer ein Ziel. */
|
||||
export async function zieleVonPrivatVerwendungen(where: {
|
||||
schluesselId?: string;
|
||||
hostId?: string;
|
||||
}): Promise<Ziel[]> {
|
||||
const verwendungen = await prisma.privateVerwendung.findMany({
|
||||
where: {
|
||||
...(where.schluesselId ? { schluesselId: where.schluesselId } : {}),
|
||||
...(where.hostId ? { hostId: where.hostId } : {}),
|
||||
zustand: { not: "ENTFERNT" },
|
||||
host: { archiviertAm: null },
|
||||
},
|
||||
select: { hostId: true, zielbenutzer: true },
|
||||
});
|
||||
const ziele = new Map<string, Ziel>();
|
||||
for (const v of verwendungen) {
|
||||
ziele.set(`${v.hostId}\u0000${v.zielbenutzer}`, {
|
||||
hostId: v.hostId,
|
||||
zielbenutzer: v.zielbenutzer,
|
||||
art: "privat",
|
||||
});
|
||||
}
|
||||
return [...ziele.values()];
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ 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 { zieleVonPrivatVerwendungen, zieleVonVerwendungen, type Ziel } from "@/server/services/soll";
|
||||
import { SshConfigFehler, privatPfadPruefen, sshConfigPruefen } from "@/lib/ssh/ssh-config";
|
||||
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||
|
||||
/**
|
||||
@@ -120,13 +121,20 @@ export async function trockenlaufStarten(
|
||||
auswahl: { schluesselId?: string; hostId?: string; verwendungIds?: string[]; ziele?: Ziel[] },
|
||||
akteur: Akteur,
|
||||
): Promise<string> {
|
||||
const ziele =
|
||||
auswahl.ziele ??
|
||||
(await zieleVonVerwendungen({
|
||||
const ziele = auswahl.ziele ?? [
|
||||
...(await zieleVonVerwendungen({
|
||||
...(auswahl.schluesselId ? { schluesselId: auswahl.schluesselId } : {}),
|
||||
...(auswahl.hostId ? { hostId: auswahl.hostId } : {}),
|
||||
...(auswahl.verwendungIds ? { ids: auswahl.verwendungIds } : {}),
|
||||
}));
|
||||
})),
|
||||
// Private Keys nur, wenn nicht gezielt einzelne Public-Verwendungen gemeint sind.
|
||||
...(auswahl.verwendungIds
|
||||
? []
|
||||
: await zieleVonPrivatVerwendungen({
|
||||
...(auswahl.schluesselId ? { schluesselId: auswahl.schluesselId } : {}),
|
||||
...(auswahl.hostId ? { hostId: auswahl.hostId } : {}),
|
||||
})),
|
||||
];
|
||||
if (ziele.length === 0) throw new VerwendungsFehler("Es gibt nichts auszurollen.");
|
||||
return vorgangEinreihen({
|
||||
art: "TROCKENLAUF",
|
||||
@@ -150,3 +158,115 @@ export async function ausrollenStarten(trockenlaufId: string, akteur: Akteur): P
|
||||
eingabe: { trockenlaufId },
|
||||
});
|
||||
}
|
||||
|
||||
export type PrivateZuweisung = {
|
||||
readonly schluesselId: string;
|
||||
readonly hostId: string;
|
||||
readonly zielbenutzer: string;
|
||||
readonly pfad: string;
|
||||
/** Optionaler Block in ~/.ssh/config des Zielbenutzers. */
|
||||
readonly sshConfig?: { alias: string; hostName: string; benutzer: string; port: number } | null;
|
||||
/** Hosts, deren Host-Key in known_hosts gepflegt wird. */
|
||||
readonly knownHostsFuer?: string[];
|
||||
readonly beimWiderruf?: "LOESCHEN" | "UMBENENNEN";
|
||||
};
|
||||
|
||||
/**
|
||||
* Private Key einem Quell-Host zuweisen (#017). Nur Admins (Recht privateKeyAusrollen, prueft
|
||||
* die Aktion). Ein passphrasengeschuetzter Key ist fuer automatische Anmeldungen ungeeignet –
|
||||
* die Oberflaeche warnt, verboten ist er nicht (Freigabe Frage 6).
|
||||
*/
|
||||
export async function privateVerwendungAnlegen(eingabe: PrivateZuweisung, akteur: Akteur): Promise<string> {
|
||||
if (!BENUTZERNAME_MUSTER.test(eingabe.zielbenutzer))
|
||||
throw new VerwendungsFehler("Ungültiger Zielbenutzer.");
|
||||
let pfad: string;
|
||||
try {
|
||||
pfad = privatPfadPruefen(eingabe.pfad);
|
||||
if (eingabe.sshConfig) sshConfigPruefen({ ...eingabe.sshConfig, identityFile: pfad });
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof SshConfigFehler) throw new VerwendungsFehler(fehler.message);
|
||||
throw fehler;
|
||||
}
|
||||
const schluessel = await prisma.schluessel.findUnique({
|
||||
where: { id: eingabe.schluesselId },
|
||||
select: {
|
||||
status: true,
|
||||
fingerprint: true,
|
||||
kategorie: true,
|
||||
privatGeloeschtAm: true,
|
||||
masterKeyVersion: 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 ausrollen.");
|
||||
if (schluessel.kategorie !== "DIENST")
|
||||
throw new VerwendungsFehler("Private Keys werden nur für Service-Schlüssel ausgerollt.");
|
||||
// masterKeyVersion ist genau dann gesetzt, wenn ein Private Key gespeichert ist (DB-CHECK).
|
||||
if (schluessel.masterKeyVersion === null) {
|
||||
throw new VerwendungsFehler(
|
||||
schluessel.privatGeloeschtAm
|
||||
? "Der Private Key wurde aus SSHKeyVault gelöscht."
|
||||
: "Zu diesem Schlüssel ist kein Private Key gespeichert.",
|
||||
);
|
||||
}
|
||||
const host = await prisma.host.findUnique({
|
||||
where: { id: eingabe.hostId },
|
||||
select: { name: true, archiviertAm: true },
|
||||
});
|
||||
if (!host || host.archiviertAm) throw new VerwendungsFehler("Den Host gibt es nicht (mehr).");
|
||||
|
||||
try {
|
||||
const verwendung = await prisma.privateVerwendung.create({
|
||||
data: {
|
||||
schluesselId: eingabe.schluesselId,
|
||||
hostId: eingabe.hostId,
|
||||
zielbenutzer: eingabe.zielbenutzer,
|
||||
pfad,
|
||||
sshConfig: eingabe.sshConfig ?? undefined,
|
||||
knownHostsFuer: eingabe.knownHostsFuer ?? [],
|
||||
beimWiderruf: eingabe.beimWiderruf ?? "LOESCHEN",
|
||||
angelegtVonId: akteur.id,
|
||||
},
|
||||
});
|
||||
await protokollieren({
|
||||
akteur,
|
||||
aktion: "verwendung.private.angelegt",
|
||||
ergebnis: "ERFOLG",
|
||||
hostName: host.name,
|
||||
zielbenutzer: eingabe.zielbenutzer,
|
||||
fingerprint: schluessel.fingerprint,
|
||||
details: { pfad, sshConfig: eingabe.sshConfig ?? null },
|
||||
});
|
||||
return verwendung.id;
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof Prisma.PrismaClientKnownRequestError && fehler.code === "P2002") {
|
||||
throw new VerwendungsFehler(
|
||||
"An diesem Pfad liegt für diesen Benutzer bereits ein verwalteter Schlüssel.",
|
||||
);
|
||||
}
|
||||
throw fehler;
|
||||
}
|
||||
}
|
||||
|
||||
export async function privateVerwendungEntfernen(id: string, akteur: Akteur): Promise<void> {
|
||||
const verwendung = await prisma.privateVerwendung.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.");
|
||||
const nieAusgerollt = verwendung.zustand === "GEPLANT" && !verwendung.zuletztAngewendetAm;
|
||||
await prisma.privateVerwendung.update({
|
||||
where: { id },
|
||||
data: { zustand: nieAusgerollt ? "ENTFERNT" : "ENTFERNEN" },
|
||||
});
|
||||
await protokollieren({
|
||||
akteur,
|
||||
aktion: "verwendung.private.entfernen",
|
||||
ergebnis: "ERFOLG",
|
||||
hostName: verwendung.host.name,
|
||||
zielbenutzer: verwendung.zielbenutzer,
|
||||
fingerprint: verwendung.schluessel.fingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user