Merge branch 'phase/017-private-key-ausrollen'
# Conflicts: # src/worker/handler.ts
This commit is contained in:
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -94,7 +94,12 @@ export async function sollFuer(hostId: string, zielbenutzer: string): Promise<So
|
|||||||
return { eintraege, pflicht, verwendungIds: { drin, raus } };
|
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. */
|
/** Alle (Host, Benutzer)-Paare, die eine Public-Key-Verwendung betrifft – Gruppen aufgeloest. */
|
||||||
export async function zieleVonVerwendungen(where: {
|
export async function zieleVonVerwendungen(where: {
|
||||||
@@ -140,3 +145,28 @@ export async function zieleVonVerwendungen(where: {
|
|||||||
}
|
}
|
||||||
return [...ziele.values()];
|
return [...ziele.values()];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ziele der Private-Key-Verwendungen (#017): je Quell-Host und Benutzer ein Ziel. */
|
||||||
|
export async function zieleVonPrivatVerwendungen(where: {
|
||||||
|
schluesselId?: string;
|
||||||
|
hostId?: string;
|
||||||
|
}): Promise<Ziel[]> {
|
||||||
|
const verwendungen = await prisma.privateVerwendung.findMany({
|
||||||
|
where: {
|
||||||
|
...(where.schluesselId ? { schluesselId: where.schluesselId } : {}),
|
||||||
|
...(where.hostId ? { hostId: where.hostId } : {}),
|
||||||
|
zustand: { not: "ENTFERNT" },
|
||||||
|
host: { archiviertAm: null },
|
||||||
|
},
|
||||||
|
select: { hostId: true, zielbenutzer: true },
|
||||||
|
});
|
||||||
|
const ziele = new Map<string, Ziel>();
|
||||||
|
for (const v of verwendungen) {
|
||||||
|
ziele.set(`${v.hostId}\u0000${v.zielbenutzer}`, {
|
||||||
|
hostId: v.hostId,
|
||||||
|
zielbenutzer: v.zielbenutzer,
|
||||||
|
art: "privat",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [...ziele.values()];
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { prisma } from "@/lib/db";
|
|||||||
import { BENUTZERNAME_MUSTER } from "@/lib/shell";
|
import { BENUTZERNAME_MUSTER } from "@/lib/shell";
|
||||||
import { OptionenFehler, optionenPruefen } from "@/lib/ssh/optionen";
|
import { OptionenFehler, optionenPruefen } from "@/lib/ssh/optionen";
|
||||||
import { protokollieren } from "@/server/services/protokoll";
|
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";
|
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -120,13 +121,20 @@ export async function trockenlaufStarten(
|
|||||||
auswahl: { schluesselId?: string; hostId?: string; verwendungIds?: string[]; ziele?: Ziel[] },
|
auswahl: { schluesselId?: string; hostId?: string; verwendungIds?: string[]; ziele?: Ziel[] },
|
||||||
akteur: Akteur,
|
akteur: Akteur,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const ziele =
|
const ziele = auswahl.ziele ?? [
|
||||||
auswahl.ziele ??
|
...(await zieleVonVerwendungen({
|
||||||
(await zieleVonVerwendungen({
|
|
||||||
...(auswahl.schluesselId ? { schluesselId: auswahl.schluesselId } : {}),
|
...(auswahl.schluesselId ? { schluesselId: auswahl.schluesselId } : {}),
|
||||||
...(auswahl.hostId ? { hostId: auswahl.hostId } : {}),
|
...(auswahl.hostId ? { hostId: auswahl.hostId } : {}),
|
||||||
...(auswahl.verwendungIds ? { ids: auswahl.verwendungIds } : {}),
|
...(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.");
|
if (ziele.length === 0) throw new VerwendungsFehler("Es gibt nichts auszurollen.");
|
||||||
return vorgangEinreihen({
|
return vorgangEinreihen({
|
||||||
art: "TROCKENLAUF",
|
art: "TROCKENLAUF",
|
||||||
@@ -150,3 +158,115 @@ export async function ausrollenStarten(trockenlaufId: string, akteur: Akteur): P
|
|||||||
eingabe: { trockenlaufId },
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ import { diffVerdichten, type DiffZeile } from "@/lib/ssh/zeilen-diff";
|
|||||||
import { sollFuer, type Ziel } from "@/server/services/soll";
|
import { sollFuer, type Ziel } from "@/server/services/soll";
|
||||||
import { protokollieren } from "@/server/services/protokoll";
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
import { VorgangsFehler, type Handler } from "@/worker/ausfuehren";
|
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 { 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
|
* 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.
|
* 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 const TROCKENLAUF_GUELTIG_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
export type PlanZiel = {
|
export type PlanZiel = {
|
||||||
|
readonly art: "public" | "privat";
|
||||||
readonly hostId: string;
|
readonly hostId: string;
|
||||||
readonly hostName: string;
|
readonly hostName: string;
|
||||||
readonly zielbenutzer: string;
|
readonly zielbenutzer: string;
|
||||||
@@ -34,8 +36,17 @@ export type PlanZiel = {
|
|||||||
readonly diff: DiffZeile[];
|
readonly diff: DiffZeile[];
|
||||||
readonly hinweise: string[];
|
readonly hinweise: string[];
|
||||||
readonly fehler?: 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[] };
|
export type Plan = { readonly ziele: PlanZiel[] };
|
||||||
|
|
||||||
function hashVon(text: string): string {
|
function hashVon(text: string): string {
|
||||||
@@ -47,7 +58,7 @@ function fehlerText(fehler: unknown): string {
|
|||||||
return "Interner Fehler – Details im Worker-Log.";
|
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;
|
const ziele = (eingabe as { ziele?: unknown } | null)?.ziele;
|
||||||
if (!Array.isArray(ziele) || ziele.length === 0)
|
if (!Array.isArray(ziele) || ziele.length === 0)
|
||||||
throw new VorgangsFehler("Der Trockenlauf nennt keine Ziele.");
|
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") {
|
if (typeof ziel.hostId !== "string" || typeof ziel.zielbenutzer !== "string") {
|
||||||
throw new VorgangsFehler("Ungültiges Ziel im Trockenlauf.");
|
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. */
|
/** Ziele nach Host gruppieren: je Host genau eine SSH-Verbindung. */
|
||||||
async function jeHost<T>(
|
async function jeHost<T>(
|
||||||
ziele: readonly Ziel[],
|
ziele: readonly ZielMitArt[],
|
||||||
arbeit: (host: Host, verbindung: Client, benutzer: string) => Promise<T>,
|
arbeit: (host: Host, verbindung: Client, ziel: ZielMitArt) => Promise<T>,
|
||||||
beiFehler: (host: Pick<Host, "id" | "name">, benutzer: string, fehler: unknown) => T,
|
beiFehler: (host: Pick<Host, "id" | "name">, ziel: ZielMitArt, fehler: unknown) => T,
|
||||||
): Promise<T[]> {
|
): Promise<T[]> {
|
||||||
const nachHost = new Map<string, string[]>();
|
const nachHost = new Map<string, ZielMitArt[]>();
|
||||||
for (const ziel of ziele)
|
for (const ziel of ziele) nachHost.set(ziel.hostId, [...(nachHost.get(ziel.hostId) ?? []), ziel]);
|
||||||
nachHost.set(ziel.hostId, [...(nachHost.get(ziel.hostId) ?? []), ziel.zielbenutzer]);
|
|
||||||
const ergebnisse: T[] = [];
|
const ergebnisse: T[] = [];
|
||||||
for (const [hostId, benutzerListe] of nachHost) {
|
for (const [hostId, hostZiele] of nachHost) {
|
||||||
const host = await prisma.host.findUnique({ where: { id: hostId } });
|
const host = await prisma.host.findUnique({ where: { id: hostId } });
|
||||||
if (!host || host.archiviertAm) {
|
if (!host || host.archiviertAm) {
|
||||||
for (const benutzer of benutzerListe) {
|
for (const ziel of hostZiele) {
|
||||||
ergebnisse.push(
|
ergebnisse.push(
|
||||||
beiFehler(
|
beiFehler(
|
||||||
{ id: hostId, name: host?.name ?? "?" },
|
{ id: hostId, name: host?.name ?? "?" },
|
||||||
benutzer,
|
ziel,
|
||||||
new VorgangsFehler("Host ist archiviert oder fehlt."),
|
new VorgangsFehler("Host ist archiviert oder fehlt."),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -88,15 +102,15 @@ async function jeHost<T>(
|
|||||||
try {
|
try {
|
||||||
verbindung = await hostVerbinden(host);
|
verbindung = await hostVerbinden(host);
|
||||||
} catch (fehler) {
|
} 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;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
for (const benutzer of benutzerListe) {
|
for (const ziel of hostZiele) {
|
||||||
try {
|
try {
|
||||||
ergebnisse.push(await arbeit(host, verbindung, benutzer));
|
ergebnisse.push(await arbeit(host, verbindung, ziel));
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
ergebnisse.push(beiFehler(host, benutzer, fehler));
|
ergebnisse.push(beiFehler(host, ziel, fehler));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -106,15 +120,35 @@ async function jeHost<T>(
|
|||||||
return ergebnisse;
|
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 }) => {
|
export const trockenlaufHandler: Handler = async ({ vorgang }) => {
|
||||||
const ziele = zieleLesen(vorgang.eingabe);
|
const ziele = zieleLesen(vorgang.eingabe);
|
||||||
const planZiele = await jeHost<PlanZiel>(
|
const planZiele = await jeHost<PlanZiel>(
|
||||||
ziele,
|
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 stand = await authorizedKeysHolen(verbindung, host, benutzer);
|
||||||
const soll = await sollFuer(host.id, benutzer);
|
const soll = await sollFuer(host.id, benutzer);
|
||||||
const ergebnis = blockSetzen(stand.inhalt, soll.eintraege, soll.pflicht);
|
const ergebnis = blockSetzen(stand.inhalt, soll.eintraege, soll.pflicht);
|
||||||
return {
|
return {
|
||||||
|
art: "public",
|
||||||
hostId: host.id,
|
hostId: host.id,
|
||||||
hostName: host.name,
|
hostName: host.name,
|
||||||
zielbenutzer: benutzer,
|
zielbenutzer: benutzer,
|
||||||
@@ -126,10 +160,11 @@ export const trockenlaufHandler: Handler = async ({ vorgang }) => {
|
|||||||
hinweise: ergebnis.hinweise,
|
hinweise: ergebnis.hinweise,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
(host, benutzer, fehler) => ({
|
(host, ziel, fehler) => ({
|
||||||
|
art: ziel.art,
|
||||||
hostId: host.id,
|
hostId: host.id,
|
||||||
hostName: host.name,
|
hostName: host.name,
|
||||||
zielbenutzer: benutzer,
|
zielbenutzer: ziel.zielbenutzer,
|
||||||
geaendert: false,
|
geaendert: false,
|
||||||
diff: [],
|
diff: [],
|
||||||
hinweise: [],
|
hinweise: [],
|
||||||
@@ -176,15 +211,37 @@ export const ausrollenHandler: Handler = async ({ vorgang }) => {
|
|||||||
type Ausgang = {
|
type Ausgang = {
|
||||||
readonly hostId: string;
|
readonly hostId: string;
|
||||||
readonly zielbenutzer: string;
|
readonly zielbenutzer: string;
|
||||||
|
readonly art: "public" | "privat";
|
||||||
readonly erfolg: boolean;
|
readonly erfolg: boolean;
|
||||||
readonly fehler?: string;
|
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>(
|
const ausgaenge = await jeHost<Ausgang>(
|
||||||
auszufuehren,
|
auszufuehren.map((z) => ({ hostId: z.hostId, zielbenutzer: z.zielbenutzer, art: z.art ?? "public" })),
|
||||||
async (host, verbindung, benutzer) => {
|
async (host, verbindung, zielAngabe) => {
|
||||||
const ziel = geplant.get(`${host.id}\u0000${benutzer}`)!;
|
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);
|
const stand = await authorizedKeysHolen(verbindung, host, benutzer);
|
||||||
if (stand.sha256 !== ziel.vorher) {
|
if (stand.sha256 !== ziel.vorher) {
|
||||||
throw new VorgangsFehler(
|
throw new VorgangsFehler(
|
||||||
@@ -215,31 +272,51 @@ export const ausrollenHandler: Handler = async ({ vorgang }) => {
|
|||||||
trockenlaufId,
|
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,
|
hostId: host.id,
|
||||||
zielbenutzer: benutzer,
|
zielbenutzer: ziel.zielbenutzer,
|
||||||
|
art: ziel.art,
|
||||||
erfolg: false,
|
erfolg: false,
|
||||||
fehler: fehlerText(fehler),
|
fehler: fehlerText(fehler),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Zustand der Verwendungen nachziehen – auch fuer Ziele ohne Aenderung, die schon stimmten.
|
// Zustand der Verwendungen nachziehen – auch fuer Ziele ohne Aenderung, die schon stimmten.
|
||||||
const fehlgeschlagen = new Set(
|
const fehlgeschlagen = new Set(ausgaenge.filter((a) => !a.erfolg).map(zielSchluessel));
|
||||||
ausgaenge.filter((a) => !a.erfolg).map((a) => `${a.hostId}\u0000${a.zielbenutzer}`),
|
|
||||||
);
|
|
||||||
// Fehlschlaege zuletzt: eine Gruppen-Verwendung, die auf einem Host scheitert, soll
|
// Fehlschlaege zuletzt: eine Gruppen-Verwendung, die auf einem Host scheitert, soll
|
||||||
// FEHLGESCHLAGEN bleiben, auch wenn ein anderer Host danach erfolgreich war.
|
// FEHLGESCHLAGEN bleiben, auch wenn ein anderer Host danach erfolgreich war.
|
||||||
const reihenfolge = plan.ziele
|
const reihenfolge = plan.ziele
|
||||||
.filter((z) => !z.fehler)
|
.filter((z) => !z.fehler)
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) =>
|
(a, b) => Number(fehlgeschlagen.has(zielSchluessel(a))) - Number(fehlgeschlagen.has(zielSchluessel(b))),
|
||||||
Number(fehlgeschlagen.has(`${a.hostId}\u0000${a.zielbenutzer}`)) -
|
|
||||||
Number(fehlgeschlagen.has(`${b.hostId}\u0000${b.zielbenutzer}`)),
|
|
||||||
);
|
);
|
||||||
for (const ziel of reihenfolge) {
|
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 soll = await sollFuer(ziel.hostId, ziel.zielbenutzer);
|
||||||
const jetzt = new Date();
|
const jetzt = new Date();
|
||||||
if (fehlgeschlagen.has(schluessel)) {
|
if (fehlgeschlagen.has(schluessel)) {
|
||||||
|
|||||||
@@ -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.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 } };
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ import { bootstrapHandler } from "@/worker/ablaeufe/bootstrap";
|
|||||||
import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey";
|
import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey";
|
||||||
import { privateKeyAbholenHandler, privateKeyLoeschenHandler } from "@/worker/ablaeufe/privatekey";
|
import { privateKeyAbholenHandler, privateKeyLoeschenHandler } from "@/worker/ablaeufe/privatekey";
|
||||||
import { schluesselErzeugenHandler, schluesselImportierenHandler } from "@/worker/ablaeufe/schluessel";
|
import { schluesselErzeugenHandler, schluesselImportierenHandler } from "@/worker/ablaeufe/schluessel";
|
||||||
|
import { verbindungstestHandler } from "@/worker/ablaeufe/verbindungstest";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Abgleich (#021) …
|
* Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Abgleich (#021) …
|
||||||
@@ -18,4 +19,5 @@ export const HANDLER: HandlerTabelle = {
|
|||||||
SCHLUESSEL_IMPORTIEREN: schluesselImportierenHandler,
|
SCHLUESSEL_IMPORTIEREN: schluesselImportierenHandler,
|
||||||
PRIVATE_KEY_ABHOLEN: privateKeyAbholenHandler,
|
PRIVATE_KEY_ABHOLEN: privateKeyAbholenHandler,
|
||||||
PRIVATE_KEY_LOESCHEN: privateKeyLoeschenHandler,
|
PRIVATE_KEY_LOESCHEN: privateKeyLoeschenHandler,
|
||||||
|
VERBINDUNGSTEST: verbindungstestHandler,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { VorgangsFehler } from "@/worker/ausfuehren";
|
|||||||
import { befehlAusfuehren } from "@/worker/ssh/verbindung";
|
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**
|
* 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.
|
* (`runuser`), damit ein Symlink root nie eine fremde Datei lesen oder schreiben laesst.
|
||||||
@@ -50,13 +51,33 @@ export async function authorizedKeysHolen(
|
|||||||
verbindung: Client,
|
verbindung: Client,
|
||||||
host: Host,
|
host: Host,
|
||||||
benutzer: string,
|
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> {
|
): Promise<DateiStand> {
|
||||||
benutzerPruefen(benutzer);
|
benutzerPruefen(benutzer);
|
||||||
|
const relativ = relativenPfadPruefen(pfad);
|
||||||
const skript = `set -eu
|
const skript = `set -eu
|
||||||
B=${shellQuote(benutzer)}
|
B=${shellQuote(benutzer)}
|
||||||
getent passwd "$B" >/dev/null || { echo ${MARKE_FEHLT}; exit 3; }
|
getent passwd "$B" >/dev/null || { echo ${MARKE_FEHLT}; exit 3; }
|
||||||
H=$(getent passwd "$B" | cut -d: -f6)
|
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`;
|
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(
|
const ergebnis = await befehlAusfuehren(
|
||||||
verbindung,
|
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.`);
|
throw new VorgangsFehler(`Den Benutzer „${benutzer}“ gibt es auf ${host.name} nicht.`);
|
||||||
}
|
}
|
||||||
if (ergebnis.code !== 0) {
|
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 zeilenende = roh.indexOf(0x0a);
|
||||||
const kopf = roh.subarray(0, zeilenende).toString("utf8");
|
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.
|
// verloren, und „bytegenau“ waere gebrochen.
|
||||||
if (!Buffer.from(inhalt.toString("utf8"), "utf8").equals(inhalt)) {
|
if (!Buffer.from(inhalt.toString("utf8"), "utf8").equals(inhalt)) {
|
||||||
throw new VorgangsFehler(
|
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 {
|
return {
|
||||||
pfad: `${home}/.ssh/authorized_keys`,
|
pfad: `${home}/${relativ}`,
|
||||||
vorhanden,
|
vorhanden,
|
||||||
sha256: inhaltHash(vorhanden ? inhalt : null),
|
sha256: inhaltHash(vorhanden ? inhalt : null),
|
||||||
inhalt: inhalt.toString("utf8"),
|
inhalt: inhalt.toString("utf8"),
|
||||||
@@ -101,8 +122,26 @@ export async function authorizedKeysSchreiben(
|
|||||||
benutzer: string,
|
benutzer: string,
|
||||||
inhalt: string,
|
inhalt: string,
|
||||||
erwartet: 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 }> {
|
): Promise<{ sicherung: boolean }> {
|
||||||
benutzerPruefen(benutzer);
|
benutzerPruefen(benutzer);
|
||||||
|
const relativ = relativenPfadPruefen(pfad);
|
||||||
if (!/^([0-9a-f]{64}|keine-datei)$/.test(erwartet)) throw new VorgangsFehler("Ungültiger Datei-Hash.");
|
if (!/^([0-9a-f]{64}|keine-datei)$/.test(erwartet)) throw new VorgangsFehler("Ungültiger Datei-Hash.");
|
||||||
const skript = `set -eu
|
const skript = `set -eu
|
||||||
umask 077
|
umask 077
|
||||||
@@ -113,29 +152,29 @@ H=$(getent passwd "$B" | cut -d: -f6)
|
|||||||
als() { runuser -u "$B" -- "$@"; }
|
als() { runuser -u "$B" -- "$@"; }
|
||||||
als mkdir -p "$H/.ssh"
|
als mkdir -p "$H/.ssh"
|
||||||
als chmod 0700 "$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
|
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; }
|
[ "$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 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"
|
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
|
fi
|
||||||
N=$(als mktemp "$H/.ssh/.sshkv.tmp.XXXXXX")
|
N=$(als mktemp "$H/.ssh/.sshkv.tmp.XXXXXX")
|
||||||
als tee "$N" >/dev/null
|
als tee "$N" >/dev/null
|
||||||
als chmod 0600 "$N"
|
als chmod ${modus} "$N"
|
||||||
als mv "$N" "$D"
|
als mv "$N" "$D"
|
||||||
echo ${MARKE_OK}`;
|
echo ${MARKE_OK}`;
|
||||||
const ergebnis = await befehlAusfuehren(verbindung, `${praefix(host)}sh -c ${shellQuote(skript)}`, inhalt);
|
const ergebnis = await befehlAusfuehren(verbindung, `${praefix(host)}sh -c ${shellQuote(skript)}`, inhalt);
|
||||||
if (ergebnis.stdout.includes(MARKE_GEAENDERT)) {
|
if (ergebnis.stdout.includes(MARKE_GEAENDERT)) {
|
||||||
throw new VorgangsFehler(
|
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)) {
|
if (ergebnis.stdout.includes(MARKE_FEHLT)) {
|
||||||
throw new VorgangsFehler(`Den Benutzer „${benutzer}“ gibt es auf ${host.name} nicht.`);
|
throw new VorgangsFehler(`Den Benutzer „${benutzer}“ gibt es auf ${host.name} nicht.`);
|
||||||
}
|
}
|
||||||
if (ergebnis.code !== 0 || !ergebnis.stdout.includes(MARKE_OK)) {
|
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 };
|
||||||
}
|
}
|
||||||
@@ -24,7 +24,7 @@ beforeEach(async () => {
|
|||||||
await deploymentKeySicherstellen();
|
await deploymentKeySicherstellen();
|
||||||
await befehlAlsRoot(
|
await befehlAlsRoot(
|
||||||
zielA,
|
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(
|
expect((await prisma.publicVerwendung.findUniqueOrThrow({ where: { id: verwendungId } })).zustand).toBe(
|
||||||
"AUSGEROLLT",
|
"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");
|
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" } });
|
const eintrag = await prisma.protokoll.findFirst({ where: { aktion: "authorized_keys.geschrieben" } });
|
||||||
expect(eintrag?.diff).toContain("+ restrict");
|
expect(eintrag?.diff).toContain("+ restrict");
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function masterKeyAnlegen(): () => void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function vorgangLaufen(
|
export async function vorgangLaufen(
|
||||||
art: "HOSTKEY_ABRUFEN" | "BOOTSTRAP" | "TROCKENLAUF" | "AUSROLLEN",
|
art: "HOSTKEY_ABRUFEN" | "BOOTSTRAP" | "TROCKENLAUF" | "AUSROLLEN" | "VERBINDUNGSTEST",
|
||||||
daten: {
|
daten: {
|
||||||
hostId?: string;
|
hostId?: string;
|
||||||
eingabe?: object;
|
eingabe?: object;
|
||||||
|
|||||||
@@ -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 <alias>` 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user