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(); + }); +});