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:
2026-10-12 10:49:28 +02:00
co-authored by Claude Opus 5.5
parent 7b6323a6a4
commit edc38d0c27
10 changed files with 1008 additions and 53 deletions
+83
View File
@@ -0,0 +1,83 @@
import { BENUTZERNAME_MUSTER } from "@/lib/shell";
/**
* Eintraege fuer ~/.ssh/config und known_hosts auf dem Quell-Host (#017).
*
* Der Block steht am **Anfang** der config: ssh nimmt je Option den ersten Treffer, und ein
* `Host *` weiter oben koennte sonst IdentityFile oder User vorgeben.
*/
export type SshConfigEintrag = {
readonly alias: string;
readonly hostName: string;
readonly benutzer: string;
readonly port: number;
readonly identityFile: string;
};
export class SshConfigFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "SshConfigFehler";
}
}
const ALIAS = /^[A-Za-z0-9._-]{1,64}$/;
const HOSTNAME = /^[A-Za-z0-9._:-]{1,253}$/;
const PFAD = /^(~\/|\/)[A-Za-z0-9._/@+-]+$/;
export function sshConfigPruefen(eintrag: SshConfigEintrag): SshConfigEintrag {
if (!ALIAS.test(eintrag.alias)) throw new SshConfigFehler("Ungültiger Alias (Buchstaben, Ziffern, . _ -).");
if (!HOSTNAME.test(eintrag.hostName)) throw new SshConfigFehler("Ungültiger HostName.");
if (!BENUTZERNAME_MUSTER.test(eintrag.benutzer)) throw new SshConfigFehler("Ungültiger Benutzer.");
if (!Number.isInteger(eintrag.port) || eintrag.port < 1 || eintrag.port > 65535)
throw new SshConfigFehler("Ungültiger Port.");
if (!PFAD.test(eintrag.identityFile) || /(^|\/)\.\.(\/|$)/.test(eintrag.identityFile)) {
throw new SshConfigFehler("Ungültiger Pfad zum Private Key.");
}
return eintrag;
}
export function sshConfigZeilen(eintraege: readonly SshConfigEintrag[]): string[] {
return eintraege.flatMap((roh) => {
const e = sshConfigPruefen(roh);
return [
`Host ${e.alias}`,
` HostName ${e.hostName}`,
` User ${e.benutzer}`,
...(e.port !== 22 ? [` Port ${e.port}`] : []),
` IdentityFile ${e.identityFile}`,
" IdentitiesOnly yes",
];
});
}
/** known_hosts-Zeile: `name`, bei abweichendem Port `[name]:port`. */
export function knownHostsZeile(
hostName: string,
port: number,
algorithmus: string,
publicKey: string,
): string {
if (!HOSTNAME.test(hostName)) throw new SshConfigFehler("Ungültiger Hostname für known_hosts.");
if (!/^[a-z0-9@.-]+$/.test(algorithmus) || !/^[A-Za-z0-9+/]+={0,2}$/.test(publicKey)) {
throw new SshConfigFehler("Ungültiger Host-Key.");
}
const name = port === 22 ? hostName : `[${hostName}]:${port}`;
return `${name} ${algorithmus} ${publicKey}`;
}
/**
* Pfad eines Private Keys: nur direkt unter ~/.ssh (#017). Gleiche Regel wie im Worker
* (relativenPfadPruefen) – hier fuer die Eingabepruefung im Web.
*/
export function privatPfadPruefen(pfad: string): string {
const sauber = pfad.trim();
const relativ = sauber.startsWith("~/") ? sauber.slice(2) : sauber;
if (!/^\.ssh\/[A-Za-z0-9._@+-]+$/.test(relativ) || relativ.includes("..")) {
throw new SshConfigFehler("Der Pfad muss direkt unter ~/.ssh liegen, z. B. ~/.ssh/id_ed25519_backup.");
}
if (/^\.ssh\/(authorized_keys2?|config|known_hosts)$/.test(relativ)) {
throw new SshConfigFehler("Dieser Dateiname ist für andere Zwecke reserviert.");
}
return `~/${relativ}`;
}
+77
View File
@@ -0,0 +1,77 @@
import {
BLOCK_ANFANG,
BLOCK_ANFANG_PRAEFIX,
BLOCK_ENDE,
BLOCK_ENDE_PRAEFIX,
} from "@/lib/ssh/verwalteter-block";
import { zeilenDiff, type DiffZeile } from "@/lib/ssh/zeilen-diff";
/**
* Verwalteter Block in beliebigen Textdateien (#017): ~/.ssh/config und known_hosts.
* Dieselben Zusagen wie bei authorized_keys (#015): Rest bytegenau, beschaedigter Block wird
* nie automatisch ueberschrieben. Leerer Soll-Block entfernt den Block ganz.
*/
export class TextblockFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "TextblockFehler";
}
}
export type TextblockErgebnis = {
readonly inhalt: string;
readonly geaendert: boolean;
readonly diff: DiffZeile[];
};
function ohneCr(zeile: string): string {
return zeile.endsWith("\r") ? zeile.slice(0, -1) : zeile;
}
export function textblockSetzen(
inhalt: string,
zeilen: readonly string[],
position: "anfang" | "ende" = "ende",
): TextblockErgebnis {
if (zeilen.some((z) => /[\r\n\x00]/.test(z)))
throw new TextblockFehler("Ein Eintrag enthält einen Zeilenumbruch.");
const bom = inhalt.startsWith("");
const text = bom ? inhalt.slice(1) : inhalt;
const endetMitZeilenende = text.length === 0 || text.endsWith("\n");
const roh = text.split("\n");
if (endetMitZeilenende) roh.pop();
const anfaenge = roh.flatMap((z, i) => (ohneCr(z).trim().startsWith(BLOCK_ANFANG_PRAEFIX) ? [i] : []));
const enden = roh.flatMap((z, i) => (ohneCr(z).trim().startsWith(BLOCK_ENDE_PRAEFIX) ? [i] : []));
if (
anfaenge.length > 1 ||
enden.length > 1 ||
anfaenge.length !== enden.length ||
(anfaenge[0] ?? 0) > (enden[0] ?? 0)
) {
throw new TextblockFehler("Der verwaltete Block ist beschädigt. Bitte die Datei von Hand bereinigen.");
}
const crlf = roh.filter((z) => z.endsWith("\r")).length * 2 >= roh.length && roh.length > 0;
const block = zeilen.length ? [BLOCK_ANFANG, ...zeilen, BLOCK_ENDE].map((z) => (crlf ? `${z}\r` : z)) : [];
let neu: string[];
let mitZeilenende = endetMitZeilenende;
if (anfaenge.length === 1) {
neu = [...roh.slice(0, anfaenge[0]), ...block, ...roh.slice(enden[0]! + 1)];
} else if (block.length === 0) {
neu = roh;
} else if (position === "anfang") {
neu = [...block, ...roh];
mitZeilenende = true;
} else {
neu = [...roh, ...block];
mitZeilenende = true;
}
const ergebnis = (bom ? "" : "") + neu.join("\n") + (mitZeilenende && neu.length ? "\n" : "");
return {
inhalt: ergebnis,
geaendert: ergebnis !== inhalt,
diff: zeilenDiff(roh.map(ohneCr), neu.map(ohneCr)),
};
}
+31 -1
View File
@@ -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()];
}
+125 -5
View File
@@ -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,
});
}
+111 -34
View File
@@ -7,11 +7,12 @@ 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 { authorizedKeysHolen, authorizedKeysSchreiben, inhaltHash } from "@/worker/ssh/ssh-dateien";
import { hostVerbinden } from "@/worker/ssh/host";
import { privatAnwenden, privatPlanen, type DateiPlan, type PrivatPlan } from "@/worker/ablaeufe/privat";
/**
* Public Keys ausrollen (#016) in zwei Schritten:
* Public Keys (#016) und Private Keys (#017) ausrollen, 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.
@@ -24,6 +25,7 @@ import { hostVerbinden } from "@/worker/ssh/host";
export const TROCKENLAUF_GUELTIG_MS = 15 * 60 * 1000;
export type PlanZiel = {
readonly art: "public" | "privat";
readonly hostId: string;
readonly hostName: string;
readonly zielbenutzer: string;
@@ -34,8 +36,17 @@ export type PlanZiel = {
readonly diff: DiffZeile[];
readonly hinweise: string[];
readonly fehler?: string;
/** Nur bei privat: je Datei Aktion und Hashes (#017). */
readonly dateien?: DateiPlan[];
readonly verwendungIds?: string[];
};
type ZielMitArt = Ziel & { readonly art: "public" | "privat" };
function zielSchluessel(ziel: { hostId: string; zielbenutzer: string; art?: string }): string {
return `${ziel.hostId}\u0000${ziel.zielbenutzer}\u0000${ziel.art ?? "public"}`;
}
export type Plan = { readonly ziele: PlanZiel[] };
function hashVon(text: string): string {
@@ -47,7 +58,7 @@ function fehlerText(fehler: unknown): string {
return "Interner Fehler – Details im Worker-Log.";
}
function zieleLesen(eingabe: unknown): Ziel[] {
function zieleLesen(eingabe: unknown): ZielMitArt[] {
const ziele = (eingabe as { ziele?: unknown } | null)?.ziele;
if (!Array.isArray(ziele) || ziele.length === 0)
throw new VorgangsFehler("Der Trockenlauf nennt keine Ziele.");
@@ -56,28 +67,31 @@ function zieleLesen(eingabe: unknown): 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 };
return {
hostId: ziel.hostId,
zielbenutzer: ziel.zielbenutzer,
art: ziel.art === "privat" ? "privat" : "public",
};
});
}
/** 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,
ziele: readonly ZielMitArt[],
arbeit: (host: Host, verbindung: Client, ziel: ZielMitArt) => Promise<T>,
beiFehler: (host: Pick<Host, "id" | "name">, ziel: ZielMitArt, 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 nachHost = new Map<string, ZielMitArt[]>();
for (const ziel of ziele) nachHost.set(ziel.hostId, [...(nachHost.get(ziel.hostId) ?? []), ziel]);
const ergebnisse: T[] = [];
for (const [hostId, benutzerListe] of nachHost) {
for (const [hostId, hostZiele] of nachHost) {
const host = await prisma.host.findUnique({ where: { id: hostId } });
if (!host || host.archiviertAm) {
for (const benutzer of benutzerListe) {
for (const ziel of hostZiele) {
ergebnisse.push(
beiFehler(
{ id: hostId, name: host?.name ?? "?" },
benutzer,
ziel,
new VorgangsFehler("Host ist archiviert oder fehlt."),
),
);
@@ -88,15 +102,15 @@ async function jeHost<T>(
try {
verbindung = await hostVerbinden(host);
} catch (fehler) {
for (const benutzer of benutzerListe) ergebnisse.push(beiFehler(host, benutzer, fehler));
for (const ziel of hostZiele) ergebnisse.push(beiFehler(host, ziel, fehler));
continue;
}
try {
for (const benutzer of benutzerListe) {
for (const ziel of hostZiele) {
try {
ergebnisse.push(await arbeit(host, verbindung, benutzer));
ergebnisse.push(await arbeit(host, verbindung, ziel));
} catch (fehler) {
ergebnisse.push(beiFehler(host, benutzer, fehler));
ergebnisse.push(beiFehler(host, ziel, fehler));
}
}
} finally {
@@ -106,15 +120,35 @@ async function jeHost<T>(
return ergebnisse;
}
/** Private-Key-Plan in die gemeinsame Form bringen: Diffs von config/known_hosts, Dateiliste. */
function privatPlanZiel(host: Host, benutzer: string, plan: PrivatPlan): PlanZiel {
const geaendert = plan.dateien.some((d) => d.aktion !== "keine");
return {
art: "privat",
hostId: host.id,
hostName: host.name,
zielbenutzer: benutzer,
geaendert,
diff: plan.dateien.flatMap((d) => (d.diff && d.aktion !== "keine" ? d.diff : [])),
hinweise: plan.dateien.filter((d) => d.hinweis).map((d) => `${d.pfad}: ${d.hinweis}`),
dateien: plan.dateien,
verwendungIds: plan.verwendungIds,
};
}
export const trockenlaufHandler: Handler = async ({ vorgang }) => {
const ziele = zieleLesen(vorgang.eingabe);
const planZiele = await jeHost<PlanZiel>(
ziele,
async (host, verbindung, benutzer) => {
async (host, verbindung, ziel) => {
const benutzer = ziel.zielbenutzer;
if (ziel.art === "privat")
return privatPlanZiel(host, benutzer, await privatPlanen(verbindung, host, 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 {
art: "public",
hostId: host.id,
hostName: host.name,
zielbenutzer: benutzer,
@@ -126,10 +160,11 @@ export const trockenlaufHandler: Handler = async ({ vorgang }) => {
hinweise: ergebnis.hinweise,
};
},
(host, benutzer, fehler) => ({
(host, ziel, fehler) => ({
art: ziel.art,
hostId: host.id,
hostName: host.name,
zielbenutzer: benutzer,
zielbenutzer: ziel.zielbenutzer,
geaendert: false,
diff: [],
hinweise: [],
@@ -176,15 +211,37 @@ export const ausrollenHandler: Handler = async ({ vorgang }) => {
type Ausgang = {
readonly hostId: string;
readonly zielbenutzer: string;
readonly art: "public" | "privat";
readonly erfolg: boolean;
readonly fehler?: string;
};
const geplant = new Map(auszufuehren.map((z) => [`${z.hostId}\u0000${z.zielbenutzer}`, z]));
const geplant = new Map(auszufuehren.map((z) => [zielSchluessel(z), z]));
const ausgaenge = await jeHost<Ausgang>(
auszufuehren,
async (host, verbindung, benutzer) => {
const ziel = geplant.get(`${host.id}\u0000${benutzer}`)!;
auszufuehren.map((z) => ({ hostId: z.hostId, zielbenutzer: z.zielbenutzer, art: z.art ?? "public" })),
async (host, verbindung, zielAngabe) => {
const ziel = geplant.get(zielSchluessel(zielAngabe))!;
const benutzer = zielAngabe.zielbenutzer;
if (zielAngabe.art === "privat") {
const anzahl = await privatAnwenden(verbindung, host, benutzer, {
dateien: ziel.dateien ?? [],
verwendungIds: ziel.verwendungIds ?? [],
});
await protokollieren({
akteur: akteur ?? "System",
aktion: "privatekey.ausgerollt",
ergebnis: "ERFOLG",
hostName: host.name,
zielbenutzer: benutzer,
// Nur Pfade und Aktionen – nie Inhalte.
diff: (ziel.dateien ?? [])
.filter((d) => d.aktion !== "keine")
.map((d) => `${d.aktion} ${d.pfad}`)
.join("\n"),
details: { dateien: anzahl, trockenlaufId },
});
return { hostId: host.id, zielbenutzer: benutzer, art: "privat", erfolg: true };
}
const stand = await authorizedKeysHolen(verbindung, host, benutzer);
if (stand.sha256 !== ziel.vorher) {
throw new VorgangsFehler(
@@ -215,31 +272,51 @@ export const ausrollenHandler: Handler = async ({ vorgang }) => {
trockenlaufId,
},
});
return { hostId: host.id, zielbenutzer: benutzer, erfolg: true };
return { hostId: host.id, zielbenutzer: benutzer, art: "public", erfolg: true };
},
(host, benutzer, fehler) => ({
(host, ziel, fehler) => ({
hostId: host.id,
zielbenutzer: benutzer,
zielbenutzer: ziel.zielbenutzer,
art: ziel.art,
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}`),
);
const fehlgeschlagen = new Set(ausgaenge.filter((a) => !a.erfolg).map(zielSchluessel));
// 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}`)),
(a, b) => Number(fehlgeschlagen.has(zielSchluessel(a))) - Number(fehlgeschlagen.has(zielSchluessel(b))),
);
for (const ziel of reihenfolge) {
const schluessel = `${ziel.hostId}\u0000${ziel.zielbenutzer}`;
const schluessel = zielSchluessel(ziel);
if (ziel.art === "privat") {
const ids = ziel.verwendungIds ?? [];
const jetzt = new Date();
if (fehlgeschlagen.has(schluessel)) {
await prisma.privateVerwendung.updateMany({
where: { id: { in: ids }, zustand: { not: "ENTFERNEN" } },
data: { zustand: "FEHLGESCHLAGEN" },
});
} else {
await prisma.privateVerwendung.updateMany({
where: { id: { in: ids }, zustand: "ENTFERNEN" },
data: { zustand: "ENTFERNT", zuletztAngewendetAm: jetzt },
});
await prisma.privateVerwendung.updateMany({
where: {
id: { in: ids },
zustand: { in: ["GEPLANT", "ABWEICHUNG", "FEHLGESCHLAGEN", "AUSGEROLLT"] },
},
data: { zustand: "AUSGEROLLT", zuletztAngewendetAm: jetzt },
});
}
continue;
}
const soll = await sollFuer(ziel.hostId, ziel.zielbenutzer);
const jetzt = new Date();
if (fehlgeschlagen.has(schluessel)) {
+290
View File
@@ -0,0 +1,290 @@
import { createHash } from "node:crypto";
import type { Client } from "ssh2";
import type { Host } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { shellQuote } from "@/lib/shell";
import { knownHostsZeile, sshConfigZeilen, type SshConfigEintrag } from "@/lib/ssh/ssh-config";
import { textblockSetzen } from "@/lib/ssh/textblock";
import { diffVerdichten, type DiffZeile } from "@/lib/ssh/zeilen-diff";
import { VorgangsFehler } from "@/worker/ausfuehren";
import { privateKeyLaden } from "@/worker/geheimnisse";
import { dateiHolen, dateiSchreiben, KEINE_DATEI, relativenPfadPruefen } from "@/worker/ssh/ssh-dateien";
import { befehlAusfuehren } from "@/worker/ssh/verbindung";
/**
* Private Keys auf einen Quell-Host ausrollen (#017): Schluesseldatei, .pub, verwalteter
* Block in ~/.ssh/config und known_hosts. Wie beim Public Key in zwei Schritten (Plan,
* dann Ausfuehren), mit Hash je Datei als optimistischer Sperre.
*
* Der Inhalt des Private Keys steht nie im Plan – nur ob die Datei angelegt, geaendert oder
* entfernt wird. Eine vorhandene fremde Datei am Zielpfad wird nie ueberschrieben.
*/
export type DateiArt = "privateKey" | "publicKey" | "config" | "knownHosts";
export type DateiAktion = "anlegen" | "aendern" | "keine" | "entfernen";
export type DateiPlan = {
readonly art: DateiArt;
readonly pfad: string;
readonly aktion: DateiAktion;
readonly vorher: string;
readonly nachher: string;
/** Nur fuer config und known_hosts. */
readonly diff?: DiffZeile[];
readonly hinweis?: string;
};
type Gewollt = {
readonly art: DateiArt;
readonly pfad: string;
/** null = Datei soll weg (Widerruf). */
readonly inhalt: Buffer | null;
readonly modus: "0600" | "0644";
readonly umbenennen?: boolean;
};
const IM_SOLL = ["GEPLANT", "AUSGEROLLT", "ABWEICHUNG", "FEHLGESCHLAGEN"];
function hash(inhalt: Buffer | null): string {
return inhalt === null ? KEINE_DATEI : createHash("sha256").update(inhalt).digest("hex");
}
/** Was in ~/.ssh eines Benutzers auf `host` liegen soll. Private Keys nur hier entschluesselt. */
async function gewollteDateien(
host: Host,
benutzer: string,
): Promise<{ dateien: Gewollt[]; verwendungIds: string[] }> {
const verwendungen = await prisma.privateVerwendung.findMany({
where: { hostId: host.id, zielbenutzer: benutzer, zustand: { not: "ENTFERNT" } },
include: { schluessel: { select: { id: true, publicKey: true, privatGeloeschtAm: true, status: true } } },
orderBy: [{ angelegtAm: "asc" }, { id: "asc" }],
});
const dateien: Gewollt[] = [];
const config: SshConfigEintrag[] = [];
const knownHosts: string[] = [];
for (const verwendung of verwendungen) {
const pfad = relativenPfadPruefen(verwendung.pfad);
const bleibt = IM_SOLL.includes(verwendung.zustand);
if (!bleibt) {
dateien.push({
art: "privateKey",
pfad,
inhalt: null,
modus: "0600",
umbenennen: verwendung.beimWiderruf === "UMBENENNEN",
});
dateien.push({ art: "publicKey", pfad: `${pfad}.pub`, inhalt: null, modus: "0644" });
continue;
}
if (verwendung.schluessel.privatGeloeschtAm) {
throw new VorgangsFehler(
"Der Private Key wurde aus SSHKeyVault gelöscht und lässt sich nicht mehr ausrollen.",
);
}
dateien.push({
art: "privateKey",
pfad,
inhalt: await privateKeyLaden(verwendung.schluessel.id),
modus: "0600",
});
dateien.push({
art: "publicKey",
pfad: `${pfad}.pub`,
inhalt: Buffer.from(`${verwendung.schluessel.publicKey.trim()}\n`),
modus: "0644",
});
const sshConfig = verwendung.sshConfig as {
alias?: string;
hostName?: string;
benutzer?: string;
port?: number;
} | null;
if (sshConfig?.alias && sshConfig.hostName && sshConfig.benutzer) {
config.push({
alias: sshConfig.alias,
hostName: sshConfig.hostName,
benutzer: sshConfig.benutzer,
port: sshConfig.port ?? 22,
identityFile: `~/${pfad}`,
});
}
for (const zielHostId of verwendung.knownHostsFuer) {
const ziel = await prisma.host.findUnique({
where: { id: zielHostId },
select: { adresse: true, port: true, hostSchluessel: { where: { status: "VERTRAUT" } } },
});
if (!ziel) continue;
// Der Name, unter dem der Quell-Host das Ziel anspricht: der HostName aus der config,
// sonst die Adresse, unter der sshkv es kennt.
const name = sshConfig?.hostName ?? ziel.adresse;
const port = sshConfig?.port ?? ziel.port;
for (const key of ziel.hostSchluessel)
knownHosts.push(knownHostsZeile(name, port, key.algorithmus, key.publicKey));
}
}
// Fuer config und known_hosts stehen hier nur die Blockzeilen; die ganze Datei entsteht
// erst zusammen mit dem vorhandenen Inhalt (blockInhalt).
dateien.push({
art: "config",
pfad: ".ssh/config",
inhalt: Buffer.from(sshConfigZeilen(config).join("\n")),
modus: "0600",
});
dateien.push({
art: "knownHosts",
pfad: ".ssh/known_hosts",
inhalt: Buffer.from([...new Set(knownHosts)].join("\n")),
modus: "0600",
});
return { dateien, verwendungIds: verwendungen.map((v) => v.id) };
}
export type PrivatPlan = { readonly dateien: DateiPlan[]; readonly verwendungIds: string[] };
/** Inhalt nach dem Setzen des Blocks fuer config/known_hosts. */
function blockInhalt(
datei: Gewollt,
vorhanden: string,
): { inhalt: string; diff: DiffZeile[]; geaendert: boolean } {
const zeilen = datei.inhalt && datei.inhalt.length ? datei.inhalt.toString("utf8").split("\n") : [];
const ergebnis = textblockSetzen(vorhanden, zeilen, datei.art === "config" ? "anfang" : "ende");
return { inhalt: ergebnis.inhalt, diff: diffVerdichten(ergebnis.diff), geaendert: ergebnis.geaendert };
}
export async function privatPlanen(verbindung: Client, host: Host, benutzer: string): Promise<PrivatPlan> {
const { dateien, verwendungIds } = await gewollteDateien(host, benutzer);
const plan: DateiPlan[] = [];
try {
for (const datei of dateien) {
const stand = await dateiHolen(verbindung, host, benutzer, datei.pfad);
if (datei.art === "config" || datei.art === "knownHosts") {
const neu = blockInhalt(datei, stand.inhalt);
plan.push({
art: datei.art,
pfad: stand.pfad,
aktion: !neu.geaendert ? "keine" : stand.vorhanden ? "aendern" : "anlegen",
vorher: stand.sha256,
nachher: hash(Buffer.from(neu.inhalt, "utf8")),
diff: neu.diff,
});
continue;
}
if (datei.inhalt === null) {
plan.push({
art: datei.art,
pfad: stand.pfad,
aktion: stand.vorhanden ? "entfernen" : "keine",
vorher: stand.sha256,
nachher: KEINE_DATEI,
...(datei.umbenennen ? { hinweis: "wird umbenannt statt gelöscht" } : {}),
});
continue;
}
const nachher = hash(datei.inhalt);
if (stand.vorhanden && stand.sha256 !== nachher && datei.art === "privateKey") {
throw new VorgangsFehler(
`${stand.pfad} auf ${host.name} existiert schon mit anderem Inhalt. sshkv überschreibt keine fremden Schlüssel – bitte anderen Pfad wählen.`,
);
}
plan.push({
art: datei.art,
pfad: stand.pfad,
aktion: stand.sha256 === nachher ? "keine" : stand.vorhanden ? "aendern" : "anlegen",
vorher: stand.sha256,
nachher,
});
}
} finally {
for (const datei of dateien) if (datei.art === "privateKey") datei.inhalt?.fill(0);
}
return { dateien: plan, verwendungIds };
}
/**
* Fuehrt den Plan aus. Berechnet alles neu und vergleicht beide Hashes je Datei mit dem Plan –
* jede Abweichung bricht ab, bevor die erste Datei geschrieben wird.
*/
export async function privatAnwenden(
verbindung: Client,
host: Host,
benutzer: string,
geplant: PrivatPlan,
): Promise<number> {
const aktuell = await privatPlanen(verbindung, host, benutzer);
const schluessel = (d: DateiPlan) => `${d.art}\u0000${d.pfad}`;
const erwartet = new Map(geplant.dateien.map((d) => [schluessel(d), d]));
if (aktuell.dateien.length !== geplant.dateien.length) {
throw new VorgangsFehler(
`Die Private-Key-Zuweisungen für ${benutzer}@${host.name} haben sich geändert. Bitte neuen Trockenlauf starten.`,
);
}
for (const datei of aktuell.dateien) {
const plan = erwartet.get(schluessel(datei));
if (!plan || plan.vorher !== datei.vorher || plan.nachher !== datei.nachher) {
throw new VorgangsFehler(
`${datei.pfad} auf ${host.name} oder die Zuweisungen haben sich seit dem Trockenlauf geändert. Bitte neuen Trockenlauf starten.`,
);
}
}
const { dateien } = await gewollteDateien(host, benutzer);
let geschrieben = 0;
try {
for (const datei of dateien) {
const plan = aktuell.dateien.find((d) => d.art === datei.art && d.pfad.endsWith(`/${datei.pfad}`));
if (!plan || plan.aktion === "keine") continue;
if (plan.aktion === "entfernen") {
await dateiEntfernen(verbindung, host, benutzer, datei.pfad, Boolean(datei.umbenennen));
} else if (datei.art === "config" || datei.art === "knownHosts") {
const stand = await dateiHolen(verbindung, host, benutzer, datei.pfad);
await dateiSchreiben(
verbindung,
host,
benutzer,
datei.pfad,
blockInhalt(datei, stand.inhalt).inhalt,
plan.vorher,
datei.modus,
);
} else {
// Keine Sicherung eines alten Private Keys: sie laege als zweite Kopie auf dem Host.
await dateiSchreiben(
verbindung,
host,
benutzer,
datei.pfad,
datei.inhalt!,
plan.vorher,
datei.modus,
false,
);
}
geschrieben += 1;
}
} finally {
for (const datei of dateien) if (datei.art === "privateKey") datei.inhalt?.fill(0);
}
return geschrieben;
}
/** Loescht oder benennt eine Datei unter ~/.ssh um (als Benutzer). */
async function dateiEntfernen(
verbindung: Client,
host: Host,
benutzer: string,
pfad: string,
umbenennen: boolean,
) {
const relativ = relativenPfadPruefen(pfad);
const skript = `set -eu
B=${shellQuote(benutzer)}
H=$(getent passwd "$B" | cut -d: -f6)
D="$H/"${shellQuote(relativ)}
${umbenennen ? 'runuser -u "$B" -- mv "$D" "$D.sshkv-widerrufen.$(date +%Y%m%d-%H%M%S)"' : 'runuser -u "$B" -- rm -f -- "$D"'}
echo SSHKV-ENTFERNT`;
const praefix = host.anmeldeart === "ROOT" ? "" : "sudo -n ";
const ergebnis = await befehlAusfuehren(verbindung, `${praefix}sh -c ${shellQuote(skript)}`);
if (ergebnis.code !== 0 || !ergebnis.stdout.includes("SSHKV-ENTFERNT")) {
throw new VorgangsFehler(`${relativ} auf ${host.name} ließ sich nicht entfernen.`);
}
}
@@ -6,7 +6,8 @@ import { VorgangsFehler } from "@/worker/ausfuehren";
import { befehlAusfuehren } from "@/worker/ssh/verbindung";
/**
* authorized_keys eines Benutzers auf einem Host lesen und schreiben (#016).
* Dateien unter ~/.ssh eines Benutzers lesen und schreiben (#016, #017): authorized_keys,
* config, known_hosts und Private Keys.
*
* 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.
@@ -50,13 +51,33 @@ export async function authorizedKeysHolen(
verbindung: Client,
host: Host,
benutzer: string,
): Promise<DateiStand> {
return dateiHolen(verbindung, host, benutzer, ".ssh/authorized_keys");
}
/** Pfade relativ zum Home des Benutzers: nur direkt unter ~/.ssh, ohne `..`. */
export function relativenPfadPruefen(pfad: string): string {
const relativ = pfad.startsWith("~/") ? pfad.slice(2) : pfad;
if (!/^\.ssh\/[A-Za-z0-9._@+-]+$/.test(relativ) || relativ.includes("..")) {
throw new VorgangsFehler(`sshkv schreibt nur Dateien direkt unter ~/.ssh („${pfad}“).`);
}
return relativ;
}
/** Liest eine Datei unter ~/.ssh des Benutzers (als dieser Benutzer). */
export async function dateiHolen(
verbindung: Client,
host: Host,
benutzer: string,
pfad: string,
): Promise<DateiStand> {
benutzerPruefen(benutzer);
const relativ = relativenPfadPruefen(pfad);
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"
D="$H/"${shellQuote(relativ)}
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,
@@ -69,7 +90,7 @@ if runuser -u "$B" -- test -e "$D"; then echo "$H ja"; runuser -u "$B" -- cat "$
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.`);
throw new VorgangsFehler(`${relativ} von ${benutzer}@${host.name} ließ sich nicht lesen.`);
}
const zeilenende = roh.indexOf(0x0a);
const kopf = roh.subarray(0, zeilenende).toString("utf8");
@@ -80,11 +101,11 @@ if runuser -u "$B" -- test -e "$D"; then echo "$H ja"; runuser -u "$B" -- cat "$
// 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.`,
`${relativ} von ${benutzer}@${host.name} enthält ungültiges UTF-8. Bitte von Hand prüfen.`,
);
}
return {
pfad: `${home}/.ssh/authorized_keys`,
pfad: `${home}/${relativ}`,
vorhanden,
sha256: inhaltHash(vorhanden ? inhalt : null),
inhalt: inhalt.toString("utf8"),
@@ -101,8 +122,26 @@ export async function authorizedKeysSchreiben(
benutzer: string,
inhalt: string,
erwartet: string,
): Promise<{ sicherung: boolean }> {
return dateiSchreiben(verbindung, host, benutzer, ".ssh/authorized_keys", inhalt, erwartet);
}
/**
* Schreibt eine Datei unter ~/.ssh (als Benutzer), wenn ihr Hash noch `erwartet` ist.
* `modus` 0600 fuer alles Geheime und authorized_keys, 0644 fuer .pub-Dateien.
*/
export async function dateiSchreiben(
verbindung: Client,
host: Host,
benutzer: string,
pfad: string,
inhalt: string | Buffer,
erwartet: string,
modus: "0600" | "0644" = "0600",
sicherungAnlegen = true,
): Promise<{ sicherung: boolean }> {
benutzerPruefen(benutzer);
const relativ = relativenPfadPruefen(pfad);
if (!/^([0-9a-f]{64}|keine-datei)$/.test(erwartet)) throw new VorgangsFehler("Ungültiger Datei-Hash.");
const skript = `set -eu
umask 077
@@ -113,29 +152,29 @@ 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"
D="$H/"${shellQuote(relativ)}
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
if ${sicherungAnlegen ? 'als test -e "$D"' : "false"}; 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 chmod ${modus} "$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.`,
`${relativ} 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.`);
throw new VorgangsFehler(`${relativ} von ${benutzer}@${host.name} ließ sich nicht schreiben.`);
}
return { sicherung: erwartet !== KEINE_DATEI };
return { sicherung: sicherungAnlegen && erwartet !== KEINE_DATEI };
}