Merge branch 'phase/017-private-key-ausrollen'

# Conflicts:
#	src/worker/handler.ts
This commit is contained in:
2026-10-12 10:54:28 +02:00
13 changed files with 1098 additions and 54 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.`);
}
}
+74
View File
@@ -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 <alias> 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 } };
};
+2
View File
@@ -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,
};
@@ -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 };
}