Merge branch 'phase/010-bootstrap'

This commit is contained in:
2026-10-12 09:52:00 +02:00
13 changed files with 602 additions and 3 deletions
+11
View File
@@ -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}$/;
+129
View File
@@ -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<string, string> = {};
const namen: Record<string, keyof SshdBefund> = {
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;
}
+8
View File
@@ -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;