Merge branch 'phase/018-benutzer-sudoers'
This commit is contained in:
@@ -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}
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -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<Host, "anmeldeart">): 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<void> {
|
||||||
|
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.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user