From 17879f4c80a836fc97c00235928fc472a3627a3d Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 09:42:45 +0200 Subject: [PATCH] feat(bootstrap): Deployment-Key per Einmal-Passwort oder Befehl (#010) Ein einziges POSIX-Skript fuer beide Wege: der Worker fuehrt es nach einer einmaligen Passwortanmeldung aus, und genau dasselbe Skript steht als Befehl zum Kopieren bereit. So gibt es keine zwei Wahrheiten darueber, was ein Bootstrap auf dem Host aendert. Das Passwort reist nur verschluesselt im Job und steht nie in Vorgang, Protokoll oder Log. Dateien im Home des Zielbenutzers liest und schreibt das Skript als dieser Benutzer: ein Symlink auf /etc/shadow fuehrt zum Abbruch statt zur Kopie (Integrationstest). sudoers wird erst nach visudo -c aktiv. Am Ende beweist eine Anmeldung per Deployment-Key, dass sshkv ohne Passwort weiterarbeiten kann. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 12 ++ src/lib/shell.ts | 11 ++ src/lib/ssh/bootstrap-skript.ts | 129 ++++++++++++++++++++++ src/lib/ssh/verwalteter-block.ts | 8 ++ src/server/actions/bootstrap.ts | 65 +++++++++++ src/server/queries/bootstrap.ts | 35 ++++++ src/worker/ablaeufe/bootstrap.ts | 113 +++++++++++++++++++ src/worker/handler.ts | 5 +- tests/bootstrap-skript.test.ts | 54 +++++++++ tests/integration/bootstrap.test.ts | 165 ++++++++++++++++++++++++++++ 10 files changed, 595 insertions(+), 2 deletions(-) create mode 100644 src/lib/shell.ts create mode 100644 src/lib/ssh/bootstrap-skript.ts create mode 100644 src/lib/ssh/verwalteter-block.ts create mode 100644 src/server/actions/bootstrap.ts create mode 100644 src/server/queries/bootstrap.ts create mode 100644 src/worker/ablaeufe/bootstrap.ts create mode 100644 tests/bootstrap-skript.test.ts create mode 100644 tests/integration/bootstrap.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 8861f21..c11bab7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,6 +91,18 @@ pnpm worker # zweites Terminal Oberflächen immer gegen den **gebauten Stand** prüfen (`pnpm build && pnpm start`) – siehe Laufzettel: `next dev` hydriert in dieser Umgebung nicht zuverlässig. +## Tests brauchen die Testdatenbank für sich allein + +`tests/hilfen/db.ts` leert vor jedem Test alle Tabellen. Zwei gleichzeitige Läufe (z. B. ein +Sub-Agent und die Hauptsitzung) räumen sich gegenseitig die Daten weg – das sieht aus wie ein +echter Fehler („No record was found for an update“). Parallel Prüfende bekommen deshalb eine +**eigene** Datenbank: + +```bash +docker exec sshkv-dev-db psql -U sshkv -c "create database sshkv_test_" +TEST_DATABASE_URL=postgresql://sshkv:sshkv@localhost:5436/sshkv_test_?schema=public pnpm test +``` + ## Vor jedem Commit ```bash diff --git a/src/lib/shell.ts b/src/lib/shell.ts new file mode 100644 index 0000000..c2c1866 --- /dev/null +++ b/src/lib/shell.ts @@ -0,0 +1,11 @@ +/** + * Quotierung fuer POSIX-sh (#010). Alles, was in Skripte auf den Hosts eingesetzt wird, + * geht hier durch – auch Werte, die die Datenbank schon per CHECK eingeschraenkt hat. + */ +export function shellQuote(wert: string): string { + if (wert.includes("\0")) throw new Error("Nullbyte in einem Shell-Wert."); + return `'${wert.replace(/'/g, `'\\''`)}'`; +} + +/** Benutzernamen, wie sie auch die Datenbank erlaubt (CHECK *_zielbenutzer_gueltig). */ +export const BENUTZERNAME_MUSTER = /^[a-z_][a-z0-9_-]{0,31}$/; diff --git a/src/lib/ssh/bootstrap-skript.ts b/src/lib/ssh/bootstrap-skript.ts new file mode 100644 index 0000000..0b95aa0 --- /dev/null +++ b/src/lib/ssh/bootstrap-skript.ts @@ -0,0 +1,129 @@ +import { BENUTZERNAME_MUSTER, shellQuote } from "@/lib/shell"; +import { + BLOCK_ANFANG, + BLOCK_ANFANG_PRAEFIX, + BLOCK_ENDE, + BLOCK_ENDE_PRAEFIX, +} from "@/lib/ssh/verwalteter-block"; +import { publicKeyZeileLesen } from "@/lib/ssh/openssh"; + +/** + * Das Bootstrap-Skript (#010): bringt den Deployment-Key auf einen neuen Host. + * + * Ein Skript fuer beide Wege – der Worker fuehrt es nach der einmaligen Passwortanmeldung + * aus, und dasselbe Skript steht als Befehl zum Kopieren in der Oberflaeche. So gibt es + * keine zwei Wahrheiten darueber, was ein Bootstrap auf dem Host veraendert. + * + * Laeuft als root und ist idempotent: + * - Dienstbenutzer `sshkv` anlegen (ohne Passwort, nur Schluessel) und + * /etc/sudoers.d/sshkv schreiben – erst nach `visudo -c`, und waehrend der Pruefung unter + * einem Namen mit Punkt, den sudo ignoriert. + * - Den Deployment-Key in den verwalteten Block der authorized_keys eintragen. Gelesen und + * geschrieben wird **als Zielbenutzer** (`runuser`): ein Symlink im Home eines Benutzers + * koennte root sonst eine fremde Datei lesen oder ueberschreiben lassen. + * - Vorher ein Backup, geschrieben ueber Temp-Datei und `mv` (atomar), Rechte 0700/0600. + * - Am Ende die sshd-Einstellungen ausgeben, die die Oberflaeche anzeigt. + */ +export type BootstrapOptionen = { + /** `sshkv` (Dienstbenutzer) oder `root`. */ + readonly benutzer: string; + readonly dienstbenutzerAnlegen: boolean; + /** Public-Key-Zeile des Deployment-Keys. */ + readonly deploymentKey: string; +}; + +export const BOOTSTRAP_ERFOLG = "SSHKV-BOOTSTRAP-OK"; +export const SSHD_MARKE = "SSHKV-SSHD:"; + +export function bootstrapSkript(optionen: BootstrapOptionen): string { + if (!BENUTZERNAME_MUSTER.test(optionen.benutzer)) throw new Error("Ungültiger Benutzername."); + // Nur eine einwandfreie Public-Key-Zeile wird eingesetzt; der Parser lehnt Steuerzeichen ab. + const key = publicKeyZeileLesen(optionen.deploymentKey); + const zeile = `${key.typ} ${key.blob.toString("base64")}${key.kommentar ? ` ${key.kommentar}` : ""}`; + + return `#!/bin/sh +# SSHKeyVault – Bootstrap. Fuehrt aus: Dienstbenutzer/sudoers (falls gewaehlt) und +# Deployment-Key im verwalteten Block von authorized_keys. Idempotent. +set -eu +umask 077 +PATH=/usr/sbin:/usr/bin:/sbin:/bin + +BENUTZER=${shellQuote(optionen.benutzer)} +KEY=${shellQuote(zeile)} +DIENSTBENUTZER=${optionen.dienstbenutzerAnlegen ? "1" : "0"} + +if [ "$(id -u)" != 0 ]; then echo "Bitte als root ausfuehren." >&2; exit 1; fi + +if [ "$DIENSTBENUTZER" = 1 ]; then + if ! id -u "$BENUTZER" >/dev/null 2>&1; then + # Ohne Passwort: useradd setzt ein gesperrtes Passwort, Anmeldung nur per Schluessel. + useradd --create-home --shell /bin/sh --comment SSHKeyVault "$BENUTZER" + fi + NEU_SUDO=$(mktemp /etc/sudoers.d/.sshkv.XXXXXX) + printf '%s\\n' '# Verwaltet von SSHKeyVault - nicht manuell bearbeiten' "$BENUTZER ALL=(ALL:ALL) NOPASSWD: ALL" > "$NEU_SUDO" + chmod 0440 "$NEU_SUDO" + if ! visudo -cf "$NEU_SUDO" >/dev/null; then rm -f "$NEU_SUDO"; echo "sudoers-Pruefung fehlgeschlagen." >&2; exit 1; fi + mv "$NEU_SUDO" /etc/sudoers.d/sshkv +fi + +HEIM=$(getent passwd "$BENUTZER" | cut -d: -f6) +if [ -z "$HEIM" ] || [ ! -d "$HEIM" ]; then echo "Kein Home-Verzeichnis fuer $BENUTZER." >&2; exit 1; fi +als() { if [ "$BENUTZER" = root ]; then "$@"; else runuser -u "$BENUTZER" -- "$@"; fi; } + +als mkdir -p "$HEIM/.ssh" +als chmod 0700 "$HEIM/.ssh" +DATEI="$HEIM/.ssh/authorized_keys" +[ -e "$DATEI" ] || als touch "$DATEI" + +if ! als cat "$DATEI" | grep -qxF "$KEY"; then + als cp -p "$DATEI" "$DATEI.sshkv.bak.$(date +%Y%m%d-%H%M%S)" + NEU=$(als mktemp "$HEIM/.ssh/.sshkv.tmp.XXXXXX") + als cat "$DATEI" | tr -d '\\r' | awk -v key="$KEY" \ + -v anfang=${shellQuote(BLOCK_ANFANG)} -v ende=${shellQuote(BLOCK_ENDE)} \ + -v pa=${shellQuote(BLOCK_ANFANG_PRAEFIX)} -v pe=${shellQuote(BLOCK_ENDE_PRAEFIX)} ' + index($0, pa) == 1 { print; drin = 1; next } + index($0, pe) == 1 { if (drin && !fertig) { print key; fertig = 1 } drin = 0; print; next } + { print } + END { if (!fertig) { print anfang; print key; print ende } } + ' | als tee "$NEU" >/dev/null + als chmod 0600 "$NEU" + als mv "$NEU" "$DATEI" +fi +als chmod 0600 "$DATEI" + +if command -v sshd >/dev/null 2>&1; then + sshd -T 2>/dev/null | grep -Ei '^(permitrootlogin|passwordauthentication|pubkeyauthentication|authorizedkeysfile) ' | sed 's/^/${SSHD_MARKE}/' || true +fi +echo ${BOOTSTRAP_ERFOLG} +`; +} + +/** Fuer die Oberflaeche: zum Einfuegen in eine root-Shell auf dem Host. */ +export function bootstrapBefehl(optionen: BootstrapOptionen): string { + return `sudo sh <<'SSHKV_BOOTSTRAP'\n${bootstrapSkript(optionen)}SSHKV_BOOTSTRAP\n`; +} + +export type SshdBefund = { + readonly permitRootLogin?: string; + readonly passwordAuthentication?: string; + readonly pubkeyAuthentication?: string; + readonly authorizedKeysFile?: string; +}; + +/** Liest die sshd-Zeilen aus der Ausgabe des Skripts. */ +export function sshdBefundLesen(ausgabe: string): SshdBefund { + const befund: Record = {}; + const namen: Record = { + permitrootlogin: "permitRootLogin", + passwordauthentication: "passwordAuthentication", + pubkeyauthentication: "pubkeyAuthentication", + authorizedkeysfile: "authorizedKeysFile", + }; + for (const zeile of ausgabe.split("\n")) { + if (!zeile.startsWith(SSHD_MARKE)) continue; + const [schluessel, ...rest] = zeile.slice(SSHD_MARKE.length).trim().split(/\s+/); + const name = schluessel ? namen[schluessel.toLowerCase()] : undefined; + if (name) befund[name] = rest.join(" "); + } + return befund; +} diff --git a/src/lib/ssh/verwalteter-block.ts b/src/lib/ssh/verwalteter-block.ts new file mode 100644 index 0000000..1d2717b --- /dev/null +++ b/src/lib/ssh/verwalteter-block.ts @@ -0,0 +1,8 @@ +/** + * Marker des verwalteten Blocks in authorized_keys (Plan, „Zuweisungen & Abgleich“). + * Erkannt wird am Praefix, damit ein Block mit verändertem Zusatztext trotzdem gefunden wird. + */ +export const BLOCK_ANFANG_PRAEFIX = "# BEGIN SSHKV MANAGED"; +export const BLOCK_ENDE_PRAEFIX = "# END SSHKV MANAGED"; +export const BLOCK_ANFANG = `${BLOCK_ANFANG_PRAEFIX} – nicht manuell bearbeiten`; +export const BLOCK_ENDE = BLOCK_ENDE_PRAEFIX; diff --git a/src/server/actions/bootstrap.ts b/src/server/actions/bootstrap.ts new file mode 100644 index 0000000..1af0e5e --- /dev/null +++ b/src/server/actions/bootstrap.ts @@ -0,0 +1,65 @@ +"use server"; + +import { verschluesseln } from "@/lib/geheimnisse"; +import { BENUTZERNAME_MUSTER } from "@/lib/shell"; +import { z } from "@/lib/validierung"; +import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht"; +import { protokollieren } from "@/server/services/protokoll"; +import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge"; + +/** + * Bootstrap starten (#010). Das Passwort verlaesst diese Funktion nur verschluesselt im Job + * und steht nie in `Vorgang.eingabe`, im Protokoll oder in einem Log. + */ +export type BootstrapAntwort = { readonly vorgangId?: string; readonly fehler?: string }; + +const passwortSchema = z.object({ + hostId: z.string().min(1).max(64), + benutzer: z.string().regex(BENUTZERNAME_MUSTER, "Ungültiger Benutzername."), + passwort: z.string().min(1).max(1024), +}); + +export async function bootstrapPerPasswortAktion( + eingabe: z.input, +): Promise { + try { + const { benutzer } = await rechtErforderlich("hostsVerwalten"); + const daten = passwortSchema.safeParse(eingabe); + if (!daten.success) return { fehler: daten.error.issues[0]?.message ?? "Eingaben unvollständig." }; + const vorgangId = await vorgangEinreihen({ + art: "BOOTSTRAP", + angefordertVonId: benutzer.id, + hostId: daten.data.hostId, + eingabe: { modus: "passwort", benutzer: daten.data.benutzer }, + geheim: verschluesseln(daten.data.passwort), + }); + await protokollieren({ + akteur: { id: benutzer.id, name: benutzer.name }, + aktion: "host.bootstrap.angefordert", + ergebnis: "ERFOLG", + details: { hostId: daten.data.hostId, anmeldung: daten.data.benutzer, art: "passwort" }, + }); + return { vorgangId }; + } catch (fehler) { + if (fehler instanceof RechteFehler) return { fehler: fehler.message }; + throw fehler; + } +} + +export async function bootstrapPruefenAktion(hostId: string): Promise { + try { + const { benutzer } = await rechtErforderlich("hostsVerwalten"); + const id = z.string().min(1).max(64).parse(hostId); + return { + vorgangId: await vorgangEinreihen({ + art: "BOOTSTRAP", + angefordertVonId: benutzer.id, + hostId: id, + eingabe: { modus: "pruefen" }, + }), + }; + } catch (fehler) { + if (fehler instanceof RechteFehler) return { fehler: fehler.message }; + throw fehler; + } +} diff --git a/src/server/queries/bootstrap.ts b/src/server/queries/bootstrap.ts new file mode 100644 index 0000000..0bf4e00 --- /dev/null +++ b/src/server/queries/bootstrap.ts @@ -0,0 +1,35 @@ +import { prisma } from "@/lib/db"; +import { bootstrapBefehl } from "@/lib/ssh/bootstrap-skript"; + +/** + * Der Befehl zum Kopieren (#010). Er enthaelt nur den Public Key des Deployment-Keys und + * darf deshalb in die Oberflaeche. + */ +export async function bootstrapBefehlFuer(hostId: string): Promise { + const host = await prisma.host.findUnique({ + where: { id: hostId }, + select: { + anmeldeart: true, + deploymentSchluessel: { select: { publicKey: true } }, + gruppen: { + select: { hostgruppe: { select: { deploymentSchluessel: { select: { publicKey: true } } } } }, + }, + }, + }); + if (!host) return undefined; + const standard = await prisma.schluessel.findFirst({ + where: { kategorie: "DEPLOYMENT", instanzStandard: true, status: "AKTIV" }, + select: { publicKey: true }, + }); + // Dieselbe Reihenfolge wie im Worker (deploymentKeyIdFuer): Host, Gruppe, Instanz. + const publicKey = + host.deploymentSchluessel?.publicKey ?? + host.gruppen.find((g) => g.hostgruppe.deploymentSchluessel)?.hostgruppe.deploymentSchluessel?.publicKey ?? + standard?.publicKey; + if (!publicKey) return undefined; + return bootstrapBefehl({ + benutzer: host.anmeldeart === "ROOT" ? "root" : "sshkv", + dienstbenutzerAnlegen: host.anmeldeart === "DIENSTBENUTZER", + deploymentKey: publicKey, + }); +} diff --git a/src/worker/ablaeufe/bootstrap.ts b/src/worker/ablaeufe/bootstrap.ts new file mode 100644 index 0000000..c782b10 --- /dev/null +++ b/src/worker/ablaeufe/bootstrap.ts @@ -0,0 +1,113 @@ +import type { Host } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { entschluesseln } from "@/lib/geheimnisse"; +import { BOOTSTRAP_ERFOLG, bootstrapSkript, sshdBefundLesen, SSHD_MARKE } from "@/lib/ssh/bootstrap-skript"; +import { BENUTZERNAME_MUSTER } from "@/lib/shell"; +import { protokollieren } from "@/server/services/protokoll"; +import { VorgangsFehler, type Handler } from "@/worker/ausfuehren"; +import { hostLaden } from "@/worker/ablaeufe/hostkey"; +import { anmeldebenutzer, deploymentKeyIdFuer, hostVerbinden } from "@/worker/ssh/host"; +import { befehlAusfuehren } from "@/worker/ssh/verbindung"; + +/** + * Bootstrap eines Hosts (#010). + * + * - `passwort`: einmalige Passwortanmeldung, Skript ausfuehren. Das Passwort kommt + * verschluesselt im Job (nie in `Vorgang.eingabe`), lebt nur fuer diesen Lauf im Speicher + * und wird nirgends geschrieben; der Worker loescht den Job danach. + * - `pruefen`: nach dem Copy-Paste-Weg nur nachsehen, ob der Deployment-Key greift. + * + * In beiden Faellen endet der Vorgang mit einer Anmeldung per Deployment-Key – erst die + * beweist, dass sshkv ab jetzt ohne Passwort arbeiten kann. + */ +type Eingabe = { modus: "passwort" | "pruefen"; benutzer?: string }; + +function eingabeLesen(roh: unknown): Eingabe { + const wert = (roh ?? {}) as Partial; + if (wert.modus !== "passwort" && wert.modus !== "pruefen") + throw new VorgangsFehler("Unbekannte Bootstrap-Art."); + if (wert.modus === "passwort" && (!wert.benutzer || !BENUTZERNAME_MUSTER.test(wert.benutzer))) { + throw new VorgangsFehler("Ungültiger Benutzer für die Passwortanmeldung."); + } + return { modus: wert.modus, ...(wert.benutzer ? { benutzer: wert.benutzer } : {}) }; +} + +async function skriptPerPasswort(host: Host, benutzer: string, passwort: string): Promise { + const deploymentKey = await prisma.schluessel.findUniqueOrThrow({ + where: { id: await deploymentKeyIdFuer(host) }, + select: { publicKey: true }, + }); + const skript = bootstrapSkript({ + benutzer: anmeldebenutzer(host), + dienstbenutzerAnlegen: host.anmeldeart === "DIENSTBENUTZER", + deploymentKey: deploymentKey.publicKey, + }); + + const verbindung = await hostVerbinden(host, { benutzer, passwort }); + try { + // Als root direkt; sonst ueber sudo, das Passwort liest sudo als erste Zeile von stdin. + const ergebnis = + benutzer === "root" + ? await befehlAusfuehren(verbindung, "sh -s", skript) + : await befehlAusfuehren(verbindung, "sudo -S -p '' sh -s", `${passwort}\n${skript}`); + if (ergebnis.code !== 0 || !ergebnis.stdout.includes(BOOTSTRAP_ERFOLG)) { + // stderr des Skripts ist von uns formuliert; die erste Zeile genuegt als Hinweis. + const grund = ergebnis.stderr.split("\n").find((zeile) => zeile.trim()) ?? `Exit-Code ${ergebnis.code}`; + throw new VorgangsFehler(`Bootstrap-Skript fehlgeschlagen: ${grund.slice(0, 200)}`); + } + return ergebnis.stdout; + } finally { + verbindung.end(); + } +} + +/** Meldet sich per Deployment-Key an und prueft sudo; liefert die sshd-Ausgabe. */ +async function mitDeploymentKeyPruefen(host: Host): Promise { + const verbindung = await hostVerbinden(host); + try { + const wer = await befehlAusfuehren(verbindung, "id -un"); + if (wer.stdout.trim() !== anmeldebenutzer(host)) { + throw new VorgangsFehler("Anmeldung per Deployment-Key landete beim falschen Benutzer."); + } + const sudo = host.anmeldeart === "ROOT" ? "" : "sudo -n "; + if (sudo) { + const probe = await befehlAusfuehren(verbindung, "sudo -n true"); + if (probe.code !== 0) + throw new VorgangsFehler("Der Dienstbenutzer darf sudo nicht ohne Passwort ausführen."); + } + const sshd = await befehlAusfuehren( + verbindung, + `${sudo}sshd -T 2>/dev/null | grep -Ei '^(permitrootlogin|passwordauthentication|pubkeyauthentication|authorizedkeysfile) ' | sed 's/^/${SSHD_MARKE}/'`, + ); + return sshd.stdout; + } finally { + verbindung.end(); + } +} + +export const bootstrapHandler: Handler = async ({ vorgang, geheim }) => { + const host = await hostLaden(vorgang.hostId); + const eingabe = eingabeLesen(vorgang.eingabe); + + let ausgabe = ""; + if (eingabe.modus === "passwort") { + if (!geheim) throw new VorgangsFehler("Das Passwort fehlt – bitte den Bootstrap erneut starten."); + ausgabe = await skriptPerPasswort(host, eingabe.benutzer!, entschluesseln(geheim)); + } + const pruefung = await mitDeploymentKeyPruefen(host); + const befund = sshdBefundLesen(`${ausgabe}\n${pruefung}`); + + await prisma.host.update({ + where: { id: host.id }, + data: { bootstrapAm: new Date(), erreichbar: true, zuletztGeprueftAm: new Date(), sshdBefund: befund }, + }); + await protokollieren({ + akteur: "System", + aktion: eingabe.modus === "passwort" ? "host.bootstrap" : "host.bootstrap.geprueft", + ergebnis: "ERFOLG", + hostName: host.name, + zielbenutzer: anmeldebenutzer(host), + details: { ...befund, anmeldung: eingabe.benutzer ?? null }, + }); + return { ergebnis: { sshd: befund, benutzer: anmeldebenutzer(host) } }; +}; diff --git a/src/worker/handler.ts b/src/worker/handler.ts index 462c042..0596c06 100644 --- a/src/worker/handler.ts +++ b/src/worker/handler.ts @@ -1,11 +1,12 @@ import type { HandlerTabelle } from "@/worker/ausfuehren"; +import { bootstrapHandler } from "@/worker/ablaeufe/bootstrap"; import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey"; /** - * Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Bootstrap (#010), - * Ausrollen (#016), Abgleich (#021) … + * Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Ausrollen (#016), Abgleich (#021) … */ export const HANDLER: HandlerTabelle = { HOSTKEY_ABRUFEN: hostKeyAbrufenHandler, ERREICHBARKEIT: erreichbarkeitHandler, + BOOTSTRAP: bootstrapHandler, }; diff --git a/tests/bootstrap-skript.test.ts b/tests/bootstrap-skript.test.ts new file mode 100644 index 0000000..7a07b23 --- /dev/null +++ b/tests/bootstrap-skript.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { bootstrapBefehl, bootstrapSkript, sshdBefundLesen } from "@/lib/ssh/bootstrap-skript"; +import { shellQuote } from "@/lib/shell"; + +/** Das Bootstrap-Skript als Text (#010); ausgefuehrt wird es im Integrationstest. */ +const KEY = + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl sshkv-deployment@x"; + +describe("Bootstrap-Skript", () => { + it("quotiert Shell-Werte sicher", () => { + expect(shellQuote("a'b")).toBe(`'a'\\''b'`); + expect(shellQuote("$(id)")).toBe(`'$(id)'`); + expect(() => shellQuote("a\0b")).toThrow(); + }); + + it("weist ungueltige Benutzer und kaputte Keys ab", () => { + expect(() => + bootstrapSkript({ benutzer: "root;id", dienstbenutzerAnlegen: false, deploymentKey: KEY }), + ).toThrow(); + expect(() => + bootstrapSkript({ + benutzer: "sshkv", + dienstbenutzerAnlegen: true, + deploymentKey: "ssh-ed25519 kaputt\nid", + }), + ).toThrow(); + }); + + it("setzt den Key quotiert ein und prueft sudoers vor dem Aktivieren", () => { + const skript = bootstrapSkript({ benutzer: "sshkv", dienstbenutzerAnlegen: true, deploymentKey: KEY }); + expect(skript).toContain(`KEY='${KEY}'`); + expect(skript).toContain("visudo -cf"); + expect(skript.indexOf("visudo -cf")).toBeLessThan(skript.indexOf('mv "$NEU_SUDO" /etc/sudoers.d/sshkv')); + expect(skript).toContain("runuser -u"); + }); + + it("ergibt einen Befehl mit Here-Document, das nichts expandiert", () => { + const befehl = bootstrapBefehl({ benutzer: "root", dienstbenutzerAnlegen: false, deploymentKey: KEY }); + expect(befehl.startsWith("sudo sh <<'SSHKV_BOOTSTRAP'\n")).toBe(true); + expect(befehl.trimEnd().endsWith("SSHKV_BOOTSTRAP")).toBe(true); + }); + + it("liest die sshd-Einstellungen aus der Ausgabe", () => { + expect( + sshdBefundLesen( + "SSHKV-SSHD:permitrootlogin without-password\nSSHKV-SSHD:passwordauthentication no\nanderes\nSSHKV-SSHD:authorizedkeysfile .ssh/authorized_keys .ssh/authorized_keys2\n", + ), + ).toEqual({ + permitRootLogin: "without-password", + passwordAuthentication: "no", + authorizedKeysFile: ".ssh/authorized_keys .ssh/authorized_keys2", + }); + }); +}); diff --git a/tests/integration/bootstrap.test.ts b/tests/integration/bootstrap.test.ts new file mode 100644 index 0000000..3107533 --- /dev/null +++ b/tests/integration/bootstrap.test.ts @@ -0,0 +1,165 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { randomBytes } from "node:crypto"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { prisma } from "@/lib/db"; +import { verschluesseln } from "@/lib/geheimnisse"; +import { bootstrapBefehl } from "@/lib/ssh/bootstrap-skript"; +import { vorgangAusfuehren } from "@/worker/ausfuehren"; +import { deploymentKeySicherstellen, masterKeyVergessen } from "@/worker/geheimnisse"; +import { HANDLER } from "@/worker/handler"; +import { hostVerbinden } from "@/worker/ssh/host"; +import { befehlAusfuehren } from "@/worker/ssh/verbindung"; +import { datenbankLeeren } from "../hilfen/db"; +import { befehlAlsRoot, rootPasswort, testPasswort, zielB, zielBereit } from "./hilfen/ziele"; + +/** Bootstrap gegen ein echtes sshd (#010). Ziel B, damit #009 auf Ziel A ungestoert bleibt. */ + +let verzeichnis: string; + +beforeAll(async () => { + await zielBereit(zielB); + verzeichnis = mkdtempSync(path.join(tmpdir(), "sshkv-master-")); + const datei = path.join(verzeichnis, "master.key"); + writeFileSync(datei, randomBytes(32), { mode: 0o600 }); + process.env["SSHKV_MASTER_KEY_FILE"] = datei; + masterKeyVergessen(); +}); + +afterAll(() => rmSync(verzeichnis, { recursive: true, force: true })); + +beforeEach(async () => { + await datenbankLeeren(); + await deploymentKeySicherstellen(); + // Jeder Test beginnt ohne Dienstbenutzer und mit leerer authorized_keys fuer root. + await befehlAlsRoot( + zielB, + "userdel -r sshkv 2>/dev/null; rm -f /etc/sudoers.d/sshkv /root/.ssh/authorized_keys*; true", + ); +}); + +async function hostMitVertrautemKey(anmeldeart: "DIENSTBENUTZER" | "ROOT" = "DIENSTBENUTZER") { + const host = await prisma.host.create({ + data: { name: "ziel-b", adresse: zielB.host, port: zielB.port, anmeldeart }, + }); + const abruf = await prisma.vorgang.create({ data: { art: "HOSTKEY_ABRUFEN", hostId: host.id } }); + await vorgangAusfuehren(abruf.id, HANDLER); + await prisma.hostSchluessel.updateMany({ where: { hostId: host.id }, data: { status: "VERTRAUT" } }); + return host; +} + +async function bootstrap(hostId: string, benutzer: string, passwort: string) { + const vorgang = await prisma.vorgang.create({ + data: { art: "BOOTSTRAP", hostId, eingabe: { modus: "passwort", benutzer } }, + }); + await vorgangAusfuehren(vorgang.id, HANDLER, verschluesseln(passwort)); + return prisma.vorgang.findUniqueOrThrow({ where: { id: vorgang.id } }); +} + +describe("Bootstrap per Passwort", () => { + it("legt Dienstbenutzer, sudoers und Deployment-Key an und meldet sich danach per Key an", async () => { + const host = await hostMitVertrautemKey(); + const vorgang = await bootstrap(host.id, "root", rootPasswort()); + expect(vorgang.fehler).toBeNull(); + expect(vorgang.status).toBe("ERLEDIGT"); + + const aktuell = await prisma.host.findUniqueOrThrow({ where: { id: host.id } }); + expect(aktuell.bootstrapAm).not.toBeNull(); + expect(aktuell.sshdBefund).toMatchObject({ permitRootLogin: "yes", passwordAuthentication: "yes" }); + + const verbindung = await hostVerbinden(aktuell); + expect((await befehlAusfuehren(verbindung, "id -un")).stdout.trim()).toBe("sshkv"); + expect((await befehlAusfuehren(verbindung, "sudo -n id -un")).stdout.trim()).toBe("root"); + const datei = await befehlAusfuehren( + verbindung, + "cat ~/.ssh/authorized_keys; stat -c %a ~/.ssh ~/.ssh/authorized_keys", + ); + verbindung.end(); + expect(datei.stdout).toMatch( + /^# BEGIN SSHKV MANAGED – nicht manuell bearbeiten\nssh-ed25519 \S+ sshkv-deployment@/, + ); + expect(datei.stdout).toContain("# END SSHKV MANAGED\n700\n600"); + + const sudoers = await befehlAlsRoot(zielB, "stat -c %a /etc/sudoers.d/sshkv"); + expect(sudoers.stdout.trim()).toBe("440"); + }); + + it("ist wiederholbar, ohne den Key doppelt einzutragen, und behaelt fremde Zeilen", async () => { + const host = await hostMitVertrautemKey("ROOT"); + await befehlAlsRoot( + zielB, + "mkdir -p /root/.ssh && echo 'ssh-ed25519 AAAAfremd admin@laptop' > /root/.ssh/authorized_keys", + ); + expect((await bootstrap(host.id, "root", rootPasswort())).status).toBe("ERLEDIGT"); + expect((await bootstrap(host.id, "root", rootPasswort())).status).toBe("ERLEDIGT"); + const datei = (await befehlAlsRoot(zielB, "cat /root/.ssh/authorized_keys")).stdout; + expect(datei.match(/sshkv-deployment@/g)).toHaveLength(1); + expect(datei.startsWith("ssh-ed25519 AAAAfremd admin@laptop\n")).toBe(true); + const sicherungen = await befehlAlsRoot(zielB, "ls /root/.ssh | grep -c sshkv.bak"); + expect(Number(sicherungen.stdout.trim())).toBe(1); + }); + + it("geht auch ueber einen Benutzer mit sudo-Passwort", async () => { + const host = await hostMitVertrautemKey(); + const vorgang = await bootstrap(host.id, "test", testPasswort()); + expect(vorgang.fehler).toBeNull(); + }); + + it("speichert das Passwort nirgends", async () => { + const host = await hostMitVertrautemKey(); + const passwort = rootPasswort(); + await bootstrap(host.id, "root", passwort); + const alles = JSON.stringify( + [await prisma.vorgang.findMany(), await prisma.protokoll.findMany(), await prisma.host.findMany()], + (_, wert: unknown) => (typeof wert === "bigint" ? String(wert) : wert), + ); + expect(alles).not.toContain(passwort); + }); + + it("scheitert mit falschem Passwort verstaendlich", async () => { + const host = await hostMitVertrautemKey(); + const vorgang = await bootstrap(host.id, "root", "falsch"); + expect(vorgang.status).toBe("FEHLGESCHLAGEN"); + expect(vorgang.fehler).toMatch(/Anmeldung wurde abgelehnt/); + }); + + it("folgt keinem Symlink im Home des Dienstbenutzers", async () => { + const host = await hostMitVertrautemKey(); + await befehlAlsRoot( + zielB, + "useradd -m -s /bin/sh sshkv && mkdir -p /home/sshkv/.ssh && ln -s /etc/shadow /home/sshkv/.ssh/authorized_keys && chown -R sshkv: /home/sshkv/.ssh", + ); + const vorher = (await befehlAlsRoot(zielB, "sha256sum /etc/shadow")).stdout; + const vorgang = await bootstrap(host.id, "root", rootPasswort()); + expect(vorgang.status).toBe("FEHLGESCHLAGEN"); + expect((await befehlAlsRoot(zielB, "sha256sum /etc/shadow")).stdout).toBe(vorher); + const kopie = await befehlAlsRoot( + zielB, + "cat /home/sshkv/.ssh/*.sshkv.* 2>/dev/null | grep -c root: || true", + ); + expect(kopie.stdout.trim()).toBe("0"); + }); +}); + +describe("Bootstrap per Befehl", () => { + it("funktioniert mit dem angezeigten Befehl und anschliessender Pruefung", async () => { + const host = await hostMitVertrautemKey(); + const deployment = await prisma.schluessel.findFirstOrThrow({ where: { kategorie: "DEPLOYMENT" } }); + const befehl = bootstrapBefehl({ + benutzer: "sshkv", + dienstbenutzerAnlegen: true, + deploymentKey: deployment.publicKey, + }); + const ausgabe = await befehlAlsRoot(zielB, befehl); + expect(ausgabe.stdout).toContain("SSHKV-BOOTSTRAP-OK"); + + const pruefung = await prisma.vorgang.create({ + data: { art: "BOOTSTRAP", hostId: host.id, eingabe: { modus: "pruefen" } }, + }); + await vorgangAusfuehren(pruefung.id, HANDLER); + const fertig = await prisma.vorgang.findUniqueOrThrow({ where: { id: pruefung.id } }); + expect(fertig.fehler).toBeNull(); + expect((await prisma.host.findUniqueOrThrow({ where: { id: host.id } })).bootstrapAm).not.toBeNull(); + }); +});