Merge branch 'phase/018-benutzer-sudoers'

This commit is contained in:
2026-10-12 10:54:12 +02:00
4 changed files with 372 additions and 0 deletions
+134
View File
@@ -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}
`;
}
+53
View File
@@ -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.`);
}
}