diff --git a/src/lib/ssh/ssh-config.ts b/src/lib/ssh/ssh-config.ts new file mode 100644 index 0000000..ff9f824 --- /dev/null +++ b/src/lib/ssh/ssh-config.ts @@ -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}`; +} diff --git a/src/lib/ssh/textblock.ts b/src/lib/ssh/textblock.ts new file mode 100644 index 0000000..4c79e17 --- /dev/null +++ b/src/lib/ssh/textblock.ts @@ -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)), + }; +} diff --git a/src/server/services/soll.ts b/src/server/services/soll.ts index 698f6fa..9c312a3 100644 --- a/src/server/services/soll.ts +++ b/src/server/services/soll.ts @@ -94,7 +94,12 @@ export async function sollFuer(hostId: string, zielbenutzer: string): Promise { + 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(); + for (const v of verwendungen) { + ziele.set(`${v.hostId}\u0000${v.zielbenutzer}`, { + hostId: v.hostId, + zielbenutzer: v.zielbenutzer, + art: "privat", + }); + } + return [...ziele.values()]; +} diff --git a/src/server/services/verwendungen.ts b/src/server/services/verwendungen.ts index f375a93..fb597cc 100644 --- a/src/server/services/verwendungen.ts +++ b/src/server/services/verwendungen.ts @@ -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 { - 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 { + 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 { + 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, + }); +} diff --git a/src/worker/ablaeufe/ausrollen.ts b/src/worker/ablaeufe/ausrollen.ts index e1ed08f..332f80f 100644 --- a/src/worker/ablaeufe/ausrollen.ts +++ b/src/worker/ablaeufe/ausrollen.ts @@ -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( - ziele: readonly Ziel[], - arbeit: (host: Host, verbindung: Client, benutzer: string) => Promise, - beiFehler: (host: Pick, benutzer: string, fehler: unknown) => T, + ziele: readonly ZielMitArt[], + arbeit: (host: Host, verbindung: Client, ziel: ZielMitArt) => Promise, + beiFehler: (host: Pick, ziel: ZielMitArt, fehler: unknown) => T, ): Promise { - const nachHost = new Map(); - for (const ziel of ziele) - nachHost.set(ziel.hostId, [...(nachHost.get(ziel.hostId) ?? []), ziel.zielbenutzer]); + const nachHost = new Map(); + 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( 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( 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( 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( - 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)) { diff --git a/src/worker/ablaeufe/privat.ts b/src/worker/ablaeufe/privat.ts new file mode 100644 index 0000000..8219f26 --- /dev/null +++ b/src/worker/ablaeufe/privat.ts @@ -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 { + 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 { + 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.`); + } +} diff --git a/src/worker/ablaeufe/verbindungstest.ts b/src/worker/ablaeufe/verbindungstest.ts new file mode 100644 index 0000000..b1773d9 --- /dev/null +++ b/src/worker/ablaeufe/verbindungstest.ts @@ -0,0 +1,74 @@ +import { prisma } from "@/lib/db"; +import { shellQuote } from "@/lib/shell"; +import { privatPfadPruefen } from "@/lib/ssh/ssh-config"; +import { protokollieren } from "@/server/services/protokoll"; +import { VorgangsFehler, type Handler } from "@/worker/ausfuehren"; +import { hostVerbinden } from "@/worker/ssh/host"; +import { befehlAusfuehren } from "@/worker/ssh/verbindung"; + +/** + * Verbindungstest Host A → Host B (#020): auf A als Zielbenutzer + * `ssh -o BatchMode=yes -o StrictHostKeyChecking=yes true`. + * + * BatchMode verhindert jede Rueckfrage (Passwort, Passphrase), StrictHostKeyChecking jede + * stille Annahme eines Host-Keys – der Test beweist also genau das, was ein Cronjob auf A + * spaeter auch braucht. + */ +export const verbindungstestHandler: Handler = async ({ vorgang }) => { + const verwendungId = (vorgang.eingabe as { privatVerwendungId?: unknown } | null)?.privatVerwendungId; + if (typeof verwendungId !== "string") throw new VorgangsFehler("Kein Private-Key-Ziel angegeben."); + const verwendung = await prisma.privateVerwendung.findUnique({ + where: { id: verwendungId }, + include: { host: true, schluessel: { select: { id: true, fingerprint: true } } }, + }); + if (!verwendung || verwendung.zustand !== "AUSGEROLLT") { + throw new VorgangsFehler("Der Private Key ist auf diesem Host (noch) nicht ausgerollt."); + } + const konfiguration = verwendung.sshConfig as { + alias?: string; + hostName?: string; + benutzer?: string; + port?: number; + } | null; + const ziel = konfiguration?.alias + ? shellQuote(konfiguration.alias) + : (() => { + throw new VorgangsFehler("Für den Test braucht die Zuweisung einen ~/.ssh/config-Eintrag (Alias)."); + })(); + privatPfadPruefen(verwendung.pfad); + + const ssh = `ssh -o BatchMode=yes -o StrictHostKeyChecking=yes -o ConnectTimeout=10 ${ziel} true`; + const praefix = verwendung.host.anmeldeart === "ROOT" ? "" : "sudo -n "; + const befehl = `${praefix}runuser -u ${shellQuote(verwendung.zielbenutzer)} -- sh -c ${shellQuote(`cd ~ && ${ssh}`)}`; + + const verbindung = await hostVerbinden(verwendung.host); + let ergebnis; + try { + ergebnis = await befehlAusfuehren(verbindung, befehl); + } finally { + verbindung.end(); + } + + const erfolg = ergebnis.code === 0; + // Die letzte Zeile von ssh auf stderr ist die aussagekraeftige (Permission denied, Host key …). + const meldung = ergebnis.stderr.trim().split("\n").filter(Boolean).pop()?.slice(0, 300) ?? ""; + await protokollieren({ + akteur: "System", + aktion: "verbindungstest", + ergebnis: erfolg ? "ERFOLG" : "FEHLER", + hostName: verwendung.host.name, + zielbenutzer: verwendung.zielbenutzer, + fingerprint: verwendung.schluessel.fingerprint, + details: { alias: konfiguration?.alias ?? null, meldung }, + }); + if (!erfolg) { + throw new VorgangsFehler( + `Verbindung von ${verwendung.zielbenutzer}@${verwendung.host.name} nach ${konfiguration?.alias} fehlgeschlagen: ${meldung || `Exit-Code ${ergebnis.code}`}`, + ); + } + await prisma.schluessel.update({ + where: { id: verwendung.schluessel.id }, + data: { zuletztVerwendetAm: new Date() }, + }); + return { ergebnis: { erfolg: true, alias: konfiguration?.alias ?? null } }; +}; diff --git a/src/worker/handler.ts b/src/worker/handler.ts index 81f4d94..78aee35 100644 --- a/src/worker/handler.ts +++ b/src/worker/handler.ts @@ -4,6 +4,7 @@ import { bootstrapHandler } from "@/worker/ablaeufe/bootstrap"; import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey"; import { privateKeyAbholenHandler, privateKeyLoeschenHandler } from "@/worker/ablaeufe/privatekey"; import { schluesselErzeugenHandler, schluesselImportierenHandler } from "@/worker/ablaeufe/schluessel"; +import { verbindungstestHandler } from "@/worker/ablaeufe/verbindungstest"; /** * Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Abgleich (#021) … @@ -18,4 +19,5 @@ export const HANDLER: HandlerTabelle = { SCHLUESSEL_IMPORTIEREN: schluesselImportierenHandler, PRIVATE_KEY_ABHOLEN: privateKeyAbholenHandler, PRIVATE_KEY_LOESCHEN: privateKeyLoeschenHandler, + VERBINDUNGSTEST: verbindungstestHandler, }; diff --git a/src/worker/ssh/authorized-keys-datei.ts b/src/worker/ssh/ssh-dateien.ts similarity index 69% rename from src/worker/ssh/authorized-keys-datei.ts rename to src/worker/ssh/ssh-dateien.ts index db2226d..da47539 100644 --- a/src/worker/ssh/authorized-keys-datei.ts +++ b/src/worker/ssh/ssh-dateien.ts @@ -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 { + 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 { 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 }; } diff --git a/tests/integration/ausrollen.test.ts b/tests/integration/ausrollen.test.ts index 47e714c..850dda1 100644 --- a/tests/integration/ausrollen.test.ts +++ b/tests/integration/ausrollen.test.ts @@ -24,7 +24,7 @@ beforeEach(async () => { await deploymentKeySicherstellen(); await befehlAlsRoot( zielA, - "userdel -r sshkv 2>/dev/null; rm -f /etc/sudoers.d/sshkv /root/.ssh/authorized_keys* /home/test/.ssh/authorized_keys*; true", + "userdel -r sshkv 2>/dev/null; rm -f /etc/sudoers.d/sshkv /home/test/.ssh/authorized_keys*; rm -rf /root/.ssh; true", ); }); @@ -101,7 +101,9 @@ describe("Public Key ausrollen", () => { expect((await prisma.publicVerwendung.findUniqueOrThrow({ where: { id: verwendungId } })).zustand).toBe( "AUSGEROLLT", ); - expect((await befehlAlsRoot(zielA, "ls /root/.ssh | grep -c sshkv.bak")).stdout.trim()).toBe("1"); + expect( + (await befehlAlsRoot(zielA, "ls /root/.ssh | grep -c authorized_keys.sshkv.bak")).stdout.trim(), + ).toBe("1"); expect((await befehlAlsRoot(zielA, "stat -c %a /root/.ssh/authorized_keys")).stdout.trim()).toBe("600"); const eintrag = await prisma.protokoll.findFirst({ where: { aktion: "authorized_keys.geschrieben" } }); expect(eintrag?.diff).toContain("+ restrict"); diff --git a/tests/integration/hilfen/host.ts b/tests/integration/hilfen/host.ts index 455352b..2c5035e 100644 --- a/tests/integration/hilfen/host.ts +++ b/tests/integration/hilfen/host.ts @@ -21,7 +21,7 @@ export function masterKeyAnlegen(): () => void { } export async function vorgangLaufen( - art: "HOSTKEY_ABRUFEN" | "BOOTSTRAP" | "TROCKENLAUF" | "AUSROLLEN", + art: "HOSTKEY_ABRUFEN" | "BOOTSTRAP" | "TROCKENLAUF" | "AUSROLLEN" | "VERBINDUNGSTEST", daten: { hostId?: string; eingabe?: object; diff --git a/tests/integration/host-zu-host.test.ts b/tests/integration/host-zu-host.test.ts new file mode 100644 index 0000000..93fc699 --- /dev/null +++ b/tests/integration/host-zu-host.test.ts @@ -0,0 +1,182 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { prisma } from "@/lib/db"; +import { + publicVerwendungAnlegen, + privateVerwendungAnlegen, + privateVerwendungEntfernen, +} from "@/server/services/verwendungen"; +import { zieleVonPrivatVerwendungen, zieleVonVerwendungen } from "@/server/services/soll"; +import type { Plan } from "@/worker/ablaeufe/ausrollen"; +import { deploymentKeySicherstellen, schluesselpaarVersiegeltErzeugen } from "@/worker/geheimnisse"; +import { datenbankLeeren } from "../hilfen/db"; +import { eingerichteterHost, masterKeyAnlegen, vorgangLaufen } from "./hilfen/host"; +import { befehlAlsRoot, zielA, zielB, zielBereit } from "./hilfen/ziele"; + +/** + * Kern-Anwendungsfall (#017): Schluesselpaar erzeugen, Private Key auf A, Public Key auf B – + * danach meldet sich A per `ssh ` bei B an, ohne Rueckfrage zum Host-Key. + */ + +const AKTEUR = { id: "test", name: "Test" }; +let aufraeumen: () => void; + +beforeAll(async () => { + await Promise.all([zielBereit(zielA), zielBereit(zielB)]); + aufraeumen = masterKeyAnlegen(); +}); +afterAll(() => aufraeumen()); + +beforeEach(async () => { + await datenbankLeeren(); + await deploymentKeySicherstellen(); + const reset = "userdel -r sshkv 2>/dev/null; rm -f /etc/sudoers.d/sshkv; rm -rf /root/.ssh; true"; + await Promise.all([befehlAlsRoot(zielA, reset), befehlAlsRoot(zielB, reset)]); +}); + +async function dienstSchluessel(name: string) { + const neu = await schluesselpaarVersiegeltErzeugen("ED25519", `sshkv:${name}`); + return prisma.schluessel.create({ + data: { + id: neu.id, + name, + slug: name, + typ: "ED25519", + kategorie: "DIENST", + publicKey: neu.publicKeyZeile, + fingerprint: neu.fingerprint, + kommentar: `sshkv:${name}`, + ...neu.versiegelt, + }, + }); +} + +async function trockenlaufUndAusrollen(schluesselId: string) { + const ziele = [ + ...(await zieleVonVerwendungen({ schluesselId })), + ...(await zieleVonPrivatVerwendungen({ schluesselId })), + ]; + const trockenlauf = await vorgangLaufen("TROCKENLAUF", { eingabe: { ziele } }); + expect(trockenlauf.status).toBe("ERLEDIGT"); + const plan = trockenlauf.plan as unknown as Plan; + const ausrollen = await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: trockenlauf.id } }); + return { plan, ausrollen }; +} + +describe("Host-zu-Host", () => { + it("A meldet sich nach dem Ausrollen per Alias bei B an", async () => { + const a = await eingerichteterHost(zielA, "ziel-a"); + const b = await eingerichteterHost(zielB, "ziel-b"); + const schluessel = await dienstSchluessel("backup-a-b"); + + await privateVerwendungAnlegen( + { + schluesselId: schluessel.id, + hostId: a.id, + zielbenutzer: "root", + pfad: "~/.ssh/id_ed25519_backup-a-b", + // Im Docker-Netz erreicht A das Ziel B unter seinem Dienstnamen auf Port 22. + sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "root", port: 22 }, + knownHostsFuer: [b.id], + }, + AKTEUR, + ); + await publicVerwendungAnlegen( + { + schluesselId: schluessel.id, + hostId: b.id, + zielbenutzer: "root", + optionen: "no-pty,no-agent-forwarding", + }, + AKTEUR, + ); + + const { plan, ausrollen } = await trockenlaufUndAusrollen(schluessel.id); + const privat = plan.ziele.find((z) => z.art === "privat")!; + expect(privat.dateien?.map((d) => [d.art, d.aktion])).toEqual([ + ["privateKey", "anlegen"], + ["publicKey", "anlegen"], + ["config", "anlegen"], + ["knownHosts", "anlegen"], + ]); + // Der Plan traegt keinen Schluesselinhalt. + expect(JSON.stringify(plan)).not.toContain("PRIVATE KEY"); + expect(ausrollen.fehler).toBeNull(); + + const rechte = await befehlAlsRoot( + zielA, + "stat -c '%a' /root/.ssh/id_ed25519_backup-a-b /root/.ssh/id_ed25519_backup-a-b.pub /root/.ssh/config", + ); + expect(rechte.stdout).toBe("600\n644\n600\n"); + const test = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un"); + expect(test.stderr).not.toMatch(/Host key verification failed/); + expect(test.stdout.trim()).toBe("root"); + + expect(await prisma.privateVerwendung.count({ where: { zustand: "AUSGEROLLT" } })).toBe(1); + + // Verbindungstest als Vorgang (#020). + const verwendung = await prisma.privateVerwendung.findFirstOrThrow(); + const test2 = await vorgangLaufen("VERBINDUNGSTEST", { eingabe: { privatVerwendungId: verwendung.id } }); + expect(test2.fehler).toBeNull(); + expect( + (await prisma.schluessel.findUniqueOrThrow({ where: { id: schluessel.id } })).zuletztVerwendetAm, + ).not.toBeNull(); + + // Ohne Public Key auf B scheitert der Test verstaendlich. + await befehlAlsRoot(zielB, "rm -f /root/.ssh/authorized_keys"); + const test3 = await vorgangLaufen("VERBINDUNGSTEST", { eingabe: { privatVerwendungId: verwendung.id } }); + expect(test3.fehler).toMatch(/Permission denied/); + const protokoll = await prisma.protokoll.findFirst({ where: { aktion: "privatekey.ausgerollt" } }); + expect(protokoll?.diff).toContain("anlegen /root/.ssh/id_ed25519_backup-a-b"); + expect(JSON.stringify(protokoll?.details)).not.toContain("PRIVATE"); + }); + + it("ueberschreibt keinen fremden Schluessel am Zielpfad", async () => { + const a = await eingerichteterHost(zielA, "ziel-a"); + const schluessel = await dienstSchluessel("fremd"); + await befehlAlsRoot( + zielA, + "mkdir -p /root/.ssh && echo fremd > /root/.ssh/id_fremd && chmod 600 /root/.ssh/id_fremd", + ); + await privateVerwendungAnlegen( + { schluesselId: schluessel.id, hostId: a.id, zielbenutzer: "root", pfad: "~/.ssh/id_fremd" }, + AKTEUR, + ); + const { plan } = await trockenlaufUndAusrollen(schluessel.id); + expect(plan.ziele[0]?.fehler).toMatch(/überschreibt keine fremden Schlüssel/); + expect((await befehlAlsRoot(zielA, "cat /root/.ssh/id_fremd")).stdout).toBe("fremd\n"); + }); + + it("entfernt Schluessel, config- und known_hosts-Eintrag beim Widerruf", async () => { + const a = await eingerichteterHost(zielA, "ziel-a"); + const b = await eingerichteterHost(zielB, "ziel-b"); + const schluessel = await dienstSchluessel("weg"); + await befehlAlsRoot( + zielA, + "mkdir -p /root/.ssh && printf 'Host *\\n ServerAliveInterval 30\\n' > /root/.ssh/config", + ); + const id = await privateVerwendungAnlegen( + { + schluesselId: schluessel.id, + hostId: a.id, + zielbenutzer: "root", + pfad: "~/.ssh/id_weg", + sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "root", port: 22 }, + knownHostsFuer: [b.id], + }, + AKTEUR, + ); + await trockenlaufUndAusrollen(schluessel.id); + expect((await befehlAlsRoot(zielA, "head -n 2 /root/.ssh/config")).stdout).toBe( + "# BEGIN SSHKV MANAGED – nicht manuell bearbeiten\nHost zu-b\n", + ); + + await privateVerwendungEntfernen(id, AKTEUR); + const { ausrollen } = await trockenlaufUndAusrollen(schluessel.id); + expect(ausrollen.fehler).toBeNull(); + const rest = await befehlAlsRoot(zielA, "ls /root/.ssh; cat /root/.ssh/config"); + expect(rest.stdout).not.toContain("id_weg"); + expect(rest.stdout).toContain("Host *\n ServerAliveInterval 30\n"); + expect(rest.stdout).not.toContain("zu-b"); + expect((await befehlAlsRoot(zielA, "cat /root/.ssh/known_hosts")).stdout).not.toContain("ssh-ed25519"); + }); +}); diff --git a/tests/ssh/textblock.test.ts b/tests/ssh/textblock.test.ts new file mode 100644 index 0000000..1cf94ba --- /dev/null +++ b/tests/ssh/textblock.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { knownHostsZeile, SshConfigFehler, sshConfigZeilen } from "@/lib/ssh/ssh-config"; +import { TextblockFehler, textblockSetzen } from "@/lib/ssh/textblock"; + +/** Verwaltete Bloecke in ~/.ssh/config und known_hosts (#017). */ + +describe("Textblock", () => { + it("setzt den Block an den Anfang und laesst den Rest stehen", () => { + const vorher = "Host *\n ServerAliveInterval 30\n"; + const ergebnis = textblockSetzen(vorher, ["Host a", " HostName b"], "anfang"); + expect(ergebnis.inhalt).toBe( + "# BEGIN SSHKV MANAGED – nicht manuell bearbeiten\nHost a\n HostName b\n# END SSHKV MANAGED\n" + + vorher, + ); + expect(textblockSetzen(ergebnis.inhalt, ["Host a", " HostName b"], "anfang").geaendert).toBe(false); + }); + + it("entfernt den Block bei leerem Soll", () => { + const mit = textblockSetzen("x\n", ["a"], "ende").inhalt; + expect(textblockSetzen(mit, [], "ende").inhalt).toBe("x\n"); + expect(textblockSetzen("x\n", [], "ende").geaendert).toBe(false); + }); + + it("verweigert beschaedigte Bloecke und Zeilenumbrueche", () => { + expect(() => textblockSetzen("# BEGIN SSHKV MANAGED\n", ["a"])).toThrow(TextblockFehler); + expect(() => textblockSetzen("", ["a\nb"])).toThrow(TextblockFehler); + }); +}); + +describe("ssh-config und known_hosts", () => { + it("schreibt einen Host-Eintrag mit IdentitiesOnly", () => { + expect( + sshConfigZeilen([ + { + alias: "deploy-app01", + hostName: "10.0.0.5", + benutzer: "deploy", + port: 2222, + identityFile: "~/.ssh/id_ed25519_x", + }, + ]), + ).toEqual([ + "Host deploy-app01", + " HostName 10.0.0.5", + " User deploy", + " Port 2222", + " IdentityFile ~/.ssh/id_ed25519_x", + " IdentitiesOnly yes", + ]); + }); + + it.each([ + { alias: "a b" }, + { alias: "a\nHost *" }, + { hostName: "x;id" }, + { benutzer: "Root" }, + { port: 0 }, + { identityFile: "~/../etc/passwd" }, + ])("weist ab: %o", (fehler) => { + const basis = { alias: "a", hostName: "h", benutzer: "u", port: 22, identityFile: "~/.ssh/k" }; + expect(() => sshConfigZeilen([{ ...basis, ...fehler }])).toThrow(SshConfigFehler); + }); + + it("schreibt known_hosts mit Port in Klammern", () => { + expect(knownHostsZeile("ziel-b", 22, "ssh-ed25519", "AAAA")).toBe("ziel-b ssh-ed25519 AAAA"); + expect(knownHostsZeile("10.0.0.5", 2222, "ssh-ed25519", "AAAA")).toBe("[10.0.0.5]:2222 ssh-ed25519 AAAA"); + }); +});