diff --git a/src/lib/ssh/zielkonto.ts b/src/lib/ssh/zielkonto.ts new file mode 100644 index 0000000..0436cc6 --- /dev/null +++ b/src/lib/ssh/zielkonto.ts @@ -0,0 +1,134 @@ +import { BENUTZERNAME_MUSTER, shellQuote } from "@/lib/shell"; + +/** + * Zielbenutzer anlegen und sudoers pflegen (#018) – reine Funktionen fuer Vorschau und Skript. + * + * Die sudoers-Datei zeigt die Oberflaeche **vor** dem Anlegen genau so, wie sie geschrieben + * wird (Vorgabe: „Inhalt vorher anzeigen und mit visudo -c pruefen“). Auf dem Host wird sie + * unter einem Namen mit Punkt geprueft – den ignoriert sudo – und erst danach aktiviert. + */ +export type SudoModus = "KEINER" | "BEFEHLE" | "VOLL"; + +export class ZielkontoFehler extends Error { + constructor(nachricht: string) { + super(nachricht); + this.name = "ZielkontoFehler"; + } +} + +/** Kopfzeile, an der sshkv seine eigenen sudoers-Dateien erkennt. */ +export const SUDOERS_KOPF = "# Verwaltet von SSHKeyVault (sshkv) - nicht manuell bearbeiten"; + +/** Benutzer, die sshkv nie anlegt, aendert oder loescht. */ +const GESCHUETZT = new Set(["root", "sshkv", "daemon", "bin", "sys", "nobody", "sshd", "www-data"]); + +export function benutzerPruefen(benutzer: string): void { + if (!BENUTZERNAME_MUSTER.test(benutzer)) { + throw new ZielkontoFehler( + "Ungültiger Benutzername (Kleinbuchstaben, Ziffern, _ und -, höchstens 32 Zeichen).", + ); + } +} + +export function istGeschuetzt(benutzer: string): boolean { + return GESCHUETZT.has(benutzer); +} + +export function sudoersDatei(benutzer: string): string { + benutzerPruefen(benutzer); + return `/etc/sudoers.d/sshkv-${benutzer}`; +} + +/** + * Prueft einen erlaubten Befehl. Absoluter Pfad, Argumente erlaubt; die Zeichen, die in + * sudoers eine Bedeutung haben (`, : = \`) und Wildcards sind ausgeschlossen – eine Liste, + * die anders wirkt als sie aussieht, waere schlimmer als gar keine. + */ +export function befehlPruefen(befehl: string): string { + const sauber = befehl.trim().replace(/\s+/g, " "); + if (!sauber.startsWith("/")) + throw new ZielkontoFehler(`„${sauber}“: Befehle brauchen einen absoluten Pfad.`); + if (/[,:=\\*?[\]!"'`$;&|<>(){}\x00-\x1f\x7f]/.test(sauber)) { + throw new ZielkontoFehler(`„${sauber}“ enthält Zeichen, die in sudoers eine Sonderbedeutung haben.`); + } + if (sauber.length > 200) throw new ZielkontoFehler("Ein Befehl ist zu lang."); + return sauber; +} + +/** Inhalt der sudoers-Datei oder `null` (Modus KEINER). */ +export function sudoersInhalt( + benutzer: string, + modus: SudoModus, + befehle: readonly string[] = [], +): string | null { + benutzerPruefen(benutzer); + if (modus === "KEINER") return null; + if (modus === "VOLL") return `${SUDOERS_KOPF}\n${benutzer} ALL=(ALL:ALL) NOPASSWD: ALL\n`; + const geprueft = befehle.map(befehlPruefen).filter(Boolean); + if (geprueft.length === 0) throw new ZielkontoFehler("Bitte mindestens einen erlaubten Befehl angeben."); + return `${SUDOERS_KOPF}\n${benutzer} ALL=(root) NOPASSWD: ${geprueft.join(", ")}\n`; +} + +export const MARKE_ANGELEGT = "SSHKV-BENUTZER-ANGELEGT"; +export const MARKE_VORHANDEN = "SSHKV-BENUTZER-VORHANDEN"; +export const MARKE_FREMD = "SSHKV-SUDOERS-FREMD"; +export const MARKE_OK = "SSHKV-ZIELKONTO-OK"; + +/** + * Skript (als root): Benutzer anlegen, falls er fehlt, ~/.ssh vorbereiten und sudoers setzen + * oder entfernen. Eine vorhandene sudoers-Datei ohne sshkv-Kopf wird nie ueberschrieben. + */ +export function zielkontoSkript(benutzer: string, modus: SudoModus, befehle: readonly string[] = []): string { + benutzerPruefen(benutzer); + if (istGeschuetzt(benutzer)) + throw new ZielkontoFehler(`„${benutzer}“ verwaltet sshkv nicht als Zielkonto.`); + const inhalt = sudoersInhalt(benutzer, modus, befehle); + const datei = sudoersDatei(benutzer); + return `set -eu +umask 077 +PATH=/usr/sbin:/usr/bin:/sbin:/bin +B=${shellQuote(benutzer)} +DATEI=${shellQuote(datei)} +if id -u "$B" >/dev/null 2>&1; then + echo ${MARKE_VORHANDEN} +else + SHELL_PFAD=/bin/sh; [ -x /bin/bash ] && SHELL_PFAD=/bin/bash + useradd --create-home --shell "$SHELL_PFAD" --comment SSHKeyVault "$B" + echo ${MARKE_ANGELEGT} +fi +H=$(getent passwd "$B" | cut -d: -f6) +runuser -u "$B" -- mkdir -p "$H/.ssh" +runuser -u "$B" -- chmod 0700 "$H/.ssh" +if [ -e "$DATEI" ] && ! head -n 1 "$DATEI" | grep -qxF ${shellQuote(SUDOERS_KOPF)}; then + echo ${MARKE_FREMD}; exit 5 +fi +${ + inhalt === null + ? 'rm -f "$DATEI"' + : `NEU=$(mktemp /etc/sudoers.d/.sshkv.XXXXXX) +printf '%s' ${shellQuote(inhalt)} > "$NEU" +chmod 0440 "$NEU" +if ! visudo -cf "$NEU" >/dev/null; then rm -f "$NEU"; echo "sudoers-Prüfung fehlgeschlagen." >&2; exit 6; fi +mv "$NEU" "$DATEI"` +} +echo ${MARKE_OK} +`; +} + +/** + * Skript (als root) zum Entfernen beim Widerruf (#024): sudoers-Datei (nur die eigene) und – + * nur wenn sshkv den Benutzer angelegt hat – den Benutzer samt Home. + */ +export function zielkontoEntfernenSkript(benutzer: string, benutzerLoeschen: boolean): string { + benutzerPruefen(benutzer); + if (istGeschuetzt(benutzer)) + throw new ZielkontoFehler(`„${benutzer}“ verwaltet sshkv nicht als Zielkonto.`); + return `set -eu +PATH=/usr/sbin:/usr/bin:/sbin:/bin +B=${shellQuote(benutzer)} +DATEI=${shellQuote(sudoersDatei(benutzer))} +if [ -e "$DATEI" ] && head -n 1 "$DATEI" | grep -qxF ${shellQuote(SUDOERS_KOPF)}; then rm -f "$DATEI"; fi +${benutzerLoeschen ? 'if id -u "$B" >/dev/null 2>&1; then pkill -u "$B" 2>/dev/null || true; userdel -r "$B" 2>/dev/null || userdel "$B"; fi' : ""} +echo ${MARKE_OK} +`; +} diff --git a/src/worker/ssh/zielkonto.ts b/src/worker/ssh/zielkonto.ts new file mode 100644 index 0000000..4c2625b --- /dev/null +++ b/src/worker/ssh/zielkonto.ts @@ -0,0 +1,53 @@ +import type { Client } from "ssh2"; +import type { Host } from "@/generated/prisma/client"; +import { shellQuote } from "@/lib/shell"; +import { + MARKE_ANGELEGT, + MARKE_FREMD, + MARKE_OK, + zielkontoEntfernenSkript, + zielkontoSkript, + type SudoModus, +} from "@/lib/ssh/zielkonto"; +import { VorgangsFehler } from "@/worker/ausfuehren"; +import { befehlAusfuehren } from "@/worker/ssh/verbindung"; + +/** Zielkonten auf einem Host anlegen und entfernen (#018). Laeuft ueber die Host-Verbindung. */ + +function praefix(host: Pick): string { + return host.anmeldeart === "ROOT" ? "" : "sudo -n "; +} + +export async function zielkontoAnwenden( + verbindung: Client, + host: Host, + konto: { benutzer: string; modus: SudoModus; befehle?: readonly string[] }, +): Promise<{ angelegt: boolean }> { + const skript = zielkontoSkript(konto.benutzer, konto.modus, konto.befehle ?? []); + const ergebnis = await befehlAusfuehren(verbindung, `${praefix(host)}sh -c ${shellQuote(skript)}`); + if (ergebnis.stdout.includes(MARKE_FREMD)) { + throw new VorgangsFehler( + `Auf ${host.name} gibt es bereits /etc/sudoers.d/sshkv-${konto.benutzer}, die nicht von sshkv stammt. Bitte von Hand prüfen.`, + ); + } + if (ergebnis.code !== 0 || !ergebnis.stdout.includes(MARKE_OK)) { + const grund = ergebnis.stderr.split("\n").find((zeile) => zeile.trim()) ?? `Exit-Code ${ergebnis.code}`; + throw new VorgangsFehler( + `Zielkonto ${konto.benutzer}@${host.name} ließ sich nicht einrichten: ${grund.slice(0, 200)}`, + ); + } + return { angelegt: ergebnis.stdout.includes(MARKE_ANGELEGT) }; +} + +export async function zielkontoEntfernen( + verbindung: Client, + host: Host, + benutzer: string, + benutzerLoeschen: boolean, +): Promise { + const skript = zielkontoEntfernenSkript(benutzer, benutzerLoeschen); + const ergebnis = await befehlAusfuehren(verbindung, `${praefix(host)}sh -c ${shellQuote(skript)}`); + if (ergebnis.code !== 0 || !ergebnis.stdout.includes(MARKE_OK)) { + throw new VorgangsFehler(`Zielkonto ${benutzer}@${host.name} ließ sich nicht entfernen.`); + } +} diff --git a/tests/integration/zielkonto.test.ts b/tests/integration/zielkonto.test.ts new file mode 100644 index 0000000..73ca248 --- /dev/null +++ b/tests/integration/zielkonto.test.ts @@ -0,0 +1,120 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { deploymentKeySicherstellen } from "@/worker/geheimnisse"; +import { hostVerbinden } from "@/worker/ssh/host"; +import { befehlAusfuehren } from "@/worker/ssh/verbindung"; +import { zielkontoAnwenden, zielkontoEntfernen } from "@/worker/ssh/zielkonto"; +import { datenbankLeeren } from "../hilfen/db"; +import { eingerichteterHost, masterKeyAnlegen } from "./hilfen/host"; +import { befehlAlsRoot, zielB, zielBereit } from "./hilfen/ziele"; + +/** Zielkonten und sudoers gegen ein echtes sshd (#018). */ + +let aufraeumen: () => void; +beforeAll(async () => { + await zielBereit(zielB); + aufraeumen = masterKeyAnlegen(); +}); +afterAll(() => aufraeumen()); + +beforeEach(async () => { + await datenbankLeeren(); + await deploymentKeySicherstellen(); + await befehlAlsRoot( + zielB, + "userdel -r sshkv 2>/dev/null; userdel -r deploy 2>/dev/null; rm -f /etc/sudoers.d/sshkv /etc/sudoers.d/sshkv-deploy; true", + ); +}); + +async function verbunden() { + const host = await eingerichteterHost(zielB, "ziel-b"); + return { host, verbindung: await hostVerbinden(host) }; +} + +describe("Zielkonto", () => { + it("legt den Benutzer an und erlaubt genau die freigegebenen Befehle", async () => { + const { host, verbindung } = await verbunden(); + try { + const erstes = await zielkontoAnwenden(verbindung, host, { + benutzer: "deploy", + modus: "BEFEHLE", + befehle: ["/usr/bin/id -un"], + }); + expect(erstes.angelegt).toBe(true); + expect( + ( + await zielkontoAnwenden(verbindung, host, { + benutzer: "deploy", + modus: "BEFEHLE", + befehle: ["/usr/bin/id -un"], + }) + ).angelegt, + ).toBe(false); + const erlaubt = await befehlAusfuehren( + verbindung, + "sudo -n runuser -u deploy -- sudo -n /usr/bin/id -un", + ); + expect(erlaubt.stdout.trim()).toBe("root"); + const verboten = await befehlAusfuehren( + verbindung, + "sudo -n runuser -u deploy -- sudo -n /usr/bin/whoami", + ); + expect(verboten.code).not.toBe(0); + const rechte = await befehlAlsRoot( + zielB, + "stat -c '%a %U' /etc/sudoers.d/sshkv-deploy /home/deploy/.ssh", + ); + expect(rechte.stdout).toBe("440 root\n700 deploy\n"); + } finally { + verbindung.end(); + } + }); + + it("wechselt auf vollen Zugriff und wieder auf keinen", async () => { + const { host, verbindung } = await verbunden(); + try { + await zielkontoAnwenden(verbindung, host, { benutzer: "deploy", modus: "VOLL" }); + expect( + (await befehlAusfuehren(verbindung, "sudo -n runuser -u deploy -- sudo -n whoami")).stdout.trim(), + ).toBe("root"); + await zielkontoAnwenden(verbindung, host, { benutzer: "deploy", modus: "KEINER" }); + expect( + ( + await befehlAlsRoot(zielB, "test -e /etc/sudoers.d/sshkv-deploy && echo da || echo weg") + ).stdout.trim(), + ).toBe("weg"); + } finally { + verbindung.end(); + } + }); + + it("ueberschreibt keine fremde sudoers-Datei", async () => { + const { host, verbindung } = await verbunden(); + await befehlAlsRoot( + zielB, + "echo 'deploy ALL=(ALL) ALL' > /etc/sudoers.d/sshkv-deploy && chmod 440 /etc/sudoers.d/sshkv-deploy", + ); + try { + await expect( + zielkontoAnwenden(verbindung, host, { benutzer: "deploy", modus: "VOLL" }), + ).rejects.toThrow(/nicht von sshkv/); + expect((await befehlAlsRoot(zielB, "cat /etc/sudoers.d/sshkv-deploy")).stdout).toBe( + "deploy ALL=(ALL) ALL\n", + ); + } finally { + verbindung.end(); + } + }); + + it("entfernt sudoers und Benutzer beim Widerruf", async () => { + const { host, verbindung } = await verbunden(); + try { + await zielkontoAnwenden(verbindung, host, { benutzer: "deploy", modus: "VOLL" }); + await zielkontoEntfernen(verbindung, host, "deploy", true); + const rest = await befehlAlsRoot(zielB, "id deploy 2>&1; ls /etc/sudoers.d/"); + expect(rest.stdout).toMatch(/no such user/); + expect(rest.stdout).not.toContain("sshkv-deploy"); + } finally { + verbindung.end(); + } + }); +}); diff --git a/tests/zielkonto.test.ts b/tests/zielkonto.test.ts new file mode 100644 index 0000000..388e150 --- /dev/null +++ b/tests/zielkonto.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { + ZielkontoFehler, + befehlPruefen, + sudoersInhalt, + zielkontoEntfernenSkript, + zielkontoSkript, +} from "@/lib/ssh/zielkonto"; + +/** Zielkonten und sudoers (#018), als Text; ausgefuehrt im Integrationstest. */ + +describe("sudoers-Inhalt", () => { + it("gibt vollen Zugriff nur ausdruecklich", () => { + expect(sudoersInhalt("deploy", "VOLL")).toContain("deploy ALL=(ALL:ALL) NOPASSWD: ALL"); + expect(sudoersInhalt("deploy", "KEINER")).toBeNull(); + }); + + it("listet bestimmte Befehle", () => { + expect( + sudoersInhalt("deploy", "BEFEHLE", [ + "/usr/bin/systemctl restart app", + " /usr/bin/docker compose up -d ", + ]), + ).toBe( + "# Verwaltet von SSHKeyVault (sshkv) - nicht manuell bearbeiten\n" + + "deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart app, /usr/bin/docker compose up -d\n", + ); + expect(() => sudoersInhalt("deploy", "BEFEHLE", [])).toThrow(ZielkontoFehler); + }); + + it.each([ + ["relativer Pfad", "systemctl restart app"], + ["Komma", "/bin/a, ALL"], + ["Doppelpunkt", "/bin/a:b"], + ["Gleichheitszeichen", "/bin/a x=y"], + ["Wildcard", "/bin/rm *"], + ["Backslash", "/bin/a \\,"], + ["Zeilenumbruch", "/bin/a\nALL ALL=(ALL) ALL"], + ["Shell-Zeichen", "/bin/sh -c id;id"], + ])("weist ab: %s", (_, befehl) => { + expect(() => befehlPruefen(befehl)).toThrow(ZielkontoFehler); + }); +}); + +describe("Skripte", () => { + it("schuetzt Systemkonten", () => { + for (const benutzer of ["root", "sshkv", "nobody"]) { + expect(() => zielkontoSkript(benutzer, "VOLL")).toThrow(ZielkontoFehler); + expect(() => zielkontoEntfernenSkript(benutzer, true)).toThrow(ZielkontoFehler); + } + expect(() => zielkontoSkript("Root;id", "KEINER")).toThrow(ZielkontoFehler); + }); + + it("prueft sudoers vor dem Aktivieren und ueberschreibt keine fremde Datei", () => { + const skript = zielkontoSkript("deploy", "VOLL"); + expect(skript.indexOf("visudo -cf")).toBeLessThan(skript.indexOf('mv "$NEU" "$DATEI"')); + expect(skript).toContain("SSHKV-SUDOERS-FREMD"); + expect(zielkontoSkript("deploy", "KEINER")).toContain('rm -f "$DATEI"'); + }); + + it("loescht den Benutzer nur, wenn gewuenscht", () => { + expect(zielkontoEntfernenSkript("deploy", true)).toContain("userdel"); + expect(zielkontoEntfernenSkript("deploy", false)).not.toContain("userdel"); + }); +});