feat(bootstrap): Deployment-Key per Einmal-Passwort oder Befehl (#010)
Ein einziges POSIX-Skript fuer beide Wege: der Worker fuehrt es nach einer einmaligen Passwortanmeldung aus, und genau dasselbe Skript steht als Befehl zum Kopieren bereit. So gibt es keine zwei Wahrheiten darueber, was ein Bootstrap auf dem Host aendert. Das Passwort reist nur verschluesselt im Job und steht nie in Vorgang, Protokoll oder Log. Dateien im Home des Zielbenutzers liest und schreibt das Skript als dieser Benutzer: ein Symlink auf /etc/shadow fuehrt zum Abbruch statt zur Kopie (Integrationstest). sudoers wird erst nach visudo -c aktiv. Am Ende beweist eine Anmeldung per Deployment-Key, dass sshkv ohne Passwort weiterarbeiten kann. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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}$/;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user