# shellcheck shell=sh # # Proxmox: die Installation (Abschnitte `debian_installed` und `rebooted`) und # die Paketquellen (`proxmox_installed`). # # --------------------------------------------------------------------------- # Warum eine ISO unter QEMU und nicht installimage # --------------------------------------------------------------------------- # # Hetzners `installimage` führt kein Proxmox — es gilt dort als nicht # unterstützt. Wer es darüber versucht, installiert Debian und baut es danach # um; genau das Zwischensystem, das dieser Entwurf abgeschafft hat. # # Der reguläre Weg ist seit PVE 8.2 die offizielle ISO mit eingebetteter # Antwortdatei: `proxmox-auto-install-assistant prepare-iso` backt eine # `answer.toml` hinein, QEMU startet sie im Rettungssystem gegen die *echten* # Platten, und der Eintrag „Automated Installation" wird nach zehn Sekunden von # selbst gewählt. Kein VNC, kein Klick. # # --------------------------------------------------------------------------- # Die Codename-Tabelle # --------------------------------------------------------------------------- # # Wörtlich übernommen aus `app/Provisioning/Steps/Host/InstallProxmoxVe.php`, # das der Plattform-Plan löscht. Sie ist aus einem echten Fehler entstanden: ein # fest verdrahtetes `bookworm` hätte auf Debian 13 PVE-8-Quellen gesetzt, also # ein PVE gegen eine andere libc. # # Jede Ausgabe hat ihren EIGENEN Schlüssel und ihren eigenen Dateinamen — # Proxmox signiert jede Suite einzeln und hat die Datei zwischen PVE 8 und PVE 9 # umbenannt. Ein gemeinsamer Name zeigte irgendwann auf den falschen Schlüssel, # ohne dass etwas falsch aussähe. # # Bullseye/PVE 7 fehlt absichtlich: eine Debian-11-Maschine heute bedeutet, dass # jemand das falsche Abbild gewählt hat, und ein lautes Scheitern ist die # bessere Antwort als ein Hypervisor ohne Unterstützung. # proxmox_release_for — gibt "suite|key_url|keyring" aus, oder 1. proxmox_release_for() { case "$1" in bookworm) printf 'bookworm|https://enterprise.proxmox.com/debian/proxmox-release-bookworm.gpg|proxmox-release-bookworm.gpg' ;; trixie) printf 'trixie|https://enterprise.proxmox.com/debian/proxmox-archive-keyring-trixie.gpg|proxmox-archive-keyring.gpg' ;; *) return 1 ;; esac } # Liest ID und VERSION_CODENAME aus /etc/os-release. # # Gesourct statt gegreppt, damit die Shell das Entwerten übernimmt, und mit # einem Trenner ausgegeben, der in keinem der Werte vorkommen kann. os_release_field() { ( # shellcheck disable=SC1091 . /etc/os-release 2>/dev/null || exit 1 case "$1" in id) printf '%s' "${ID:-}" ;; codename) printf '%s' "${VERSION_CODENAME:-}" ;; pretty) printf '%s' "${PRETTY_NAME:-}" ;; esac ) } # Schreibt die Proxmox-Paketquelle im deb822-Format. # # `Signed-By` begrenzt den Schlüssel auf GENAU diese Quelle. Ein Schlüssel in # /etc/apt/trusted.gpg.d/ bürgt für JEDE Quelle der Maschine — der # Proxmox-Schlüssel vouchte dann auch für alles, was später in `sources.list` # auftaucht. Genau davor warnt apt seit mehreren Ausgaben. write_proxmox_sources() { _suite="$1" _keyring_path="$2" _file="$3" cat > "$_file" </dev/null | awk '{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i+1); exit } }' } # Setzt CLUPILOT_NET_CIDR, _GATEWAY, _DNS, _MAC aus dem, was das Rettungssystem # gerade benutzt. # # Diese Werte gehen in die Antwortdatei, weil auf einer dedizierten Maschine # kein DHCP antwortet. Was hier herauskommt, ist die Konfiguration des # INSTALLIERTEN Systems — Task 5 baut sie danach zur Brücke um, und genau dort # unterscheiden sich die Anbieter. detect_network() { _iface="$(detect_primary_interface)" if [ -z "$_iface" ]; then return 1 fi CLUPILOT_NET_IFACE="$_iface" CLUPILOT_NET_CIDR="$(ip -4 -o addr show dev "$_iface" scope global 2>/dev/null | awk '{ print $4; exit }')" CLUPILOT_NET_GATEWAY="$(ip -4 route show default 2>/dev/null | awk '{ print $3; exit }')" CLUPILOT_NET_MAC="$(cat "/sys/class/net/${_iface}/address" 2>/dev/null | tr -d ':')" CLUPILOT_NET_DNS="$(awk '/^nameserver/ { print $2; exit }' /etc/resolv.conf 2>/dev/null)" # Ohne Namensauflösung im installierten System käme es nie an die # Paketquellen. Ein öffentlicher Rückfall ist besser als ein leeres Feld, # das der Installer stillschweigend übergeht. [ -n "$CLUPILOT_NET_DNS" ] || CLUPILOT_NET_DNS='9.9.9.9' [ -n "$CLUPILOT_NET_CIDR" ] && [ -n "$CLUPILOT_NET_GATEWAY" ] && [ -n "$CLUPILOT_NET_MAC" ] } # Alle physischen Platten, kleinste zuerst nicht — Reihenfolge egal, Anzahl # zählt. Wechseldatenträger, Schleifen und RAM-Platten fliegen raus. detect_install_disks() { _disks='' for _dev in /sys/block/*; do _name="${_dev##*/}" case "$_name" in loop*|ram*|sr*|dm-*|md*|zram*|fd*) continue ;; esac [ -r "${_dev}/size" ] || continue [ "$(cat "${_dev}/size" 2>/dev/null || echo 0)" -gt 0 ] || continue # Wechseldatenträger: ein eingesteckter USB-Stick ist keine Systemplatte. [ "$(cat "${_dev}/removable" 2>/dev/null || echo 0)" = '0' ] || continue _disks="${_disks}${_disks:+ }${_name}" done printf '%s' "$_disks" } # ZFS-Aufstellung nach Plattenzahl. # # Eine Platte: raid0, weil es nichts zu spiegeln gibt — und weil ein Host mit # einer Platte ohnehin ein Ausfall pro Platte ist. Zwei: raid1. Vier oder mehr, # gerade: raid10. Drei oder eine ungerade Zahl über vier: raid1 über die ersten # zwei, und der Rest bleibt liegen, statt eine Aufstellung zu raten, die # hinterher niemand erwartet hat. zfs_raid_for() { case "$1" in 1) printf 'raid0' ;; 2) printf 'raid1' ;; 4|6|8|10) printf 'raid10' ;; *) printf 'raid1' ;; esac } # --------------------------------------------------------------------------- # Werkzeug und ISO # --------------------------------------------------------------------------- # `proxmox-auto-install-assistant` ins RETTUNGSSYSTEM. # # Die Quelle richtet sich nach dem Codename des Rettungssystems, nicht nach dem # des Ziels: das Werkzeug läuft hier, nicht dort. Unbekannter Codename bricht ab # — nicht „trixie ist das neueste, also trixie". Das nächste Debian ist auch # unbekannt, und dann sieht niemand hin. # Welche PVE-Hauptversion zu welchem Debian gehört. # # Die andere Hälfte der Tabelle: `proxmox_release_for` sagt, welche Suite zu # einem Debian passt; das hier sagt, welche PVE-Version dabei herauskommen muss. # Gebraucht wird es, um ein ABBILD abzunehmen — bei dem niemand die Suite # gewählt hat, sondern Proxmox das Paar schon zusammengestellt hatte. Passt es # nicht, ist das Abbild falsch, und kein Nachbessern der Quellen heilt das. pve_major_for_codename() { case "$1" in bookworm) printf '8' ;; trixie) printf '9' ;; *) return 1 ;; esac } # Legt den Proxmox-Release-Schlüssel ab und gibt seinen Pfad aus. # # Nach /etc/apt/keyrings/ und über `Signed-By` benannt, NICHT nach # /etc/apt/trusted.gpg.d/ — dort bürgte er für jede Quelle der Maschine. install_proxmox_key() { _key_url="$1" _keyring="$2" _keyring_path="/etc/apt/keyrings/${_keyring}" mkdir -p /etc/apt/keyrings if ! http_download "$_key_url" "$_keyring_path"; then return 1 fi chmod 644 "$_keyring_path" printf '%s' "$_keyring_path" } # Zerlegt "suite|key_url|keyring" in CLUPILOT_PVE_SUITE, _KEY_URL, _KEYRING. split_proxmox_release() { CLUPILOT_PVE_SUITE="${1%%|*}" _rest="${1#*|}" CLUPILOT_PVE_KEY_URL="${_rest%%|*}" CLUPILOT_PVE_KEYRING="${_rest##*|}" } ensure_autoinstall_assistant() { if command -v proxmox-auto-install-assistant >/dev/null 2>&1; then log 'proxmox-auto-install-assistant ist vorhanden' return 0 fi _id="$(os_release_field id)" _codename="$(os_release_field codename)" if [ "$_id" != 'debian' ] || ! _release="$(proxmox_release_for "$_codename")"; then log "Rettungssystem meldet ID=${_id:-(leer)}, VERSION_CODENAME=${_codename:-(leer)} ($(os_release_field pretty))" return 1 fi split_proxmox_release "$_release" if ! _keyring_path="$(install_proxmox_key "$CLUPILOT_PVE_KEY_URL" "$CLUPILOT_PVE_KEYRING")"; then log "Proxmox-Release-Schlüssel für ${_codename} nicht ladbar: ${CLUPILOT_PVE_KEY_URL}" return 1 fi write_proxmox_sources "$CLUPILOT_PVE_SUITE" "$_keyring_path" '/etc/apt/sources.list.d/pve-install-repo.sources' export DEBIAN_FRONTEND=noninteractive apt-get update >/dev/null 2>&1 || return 1 apt-get -y install proxmox-auto-install-assistant >/dev/null 2>&1 || return 1 command -v proxmox-auto-install-assistant >/dev/null 2>&1 } # Neueste PVE-9-ISO aus der Liste des Anbieters. # # Nicht fest verdrahtet: die Liste gehört Proxmox und wächst. Ein fester Name # wäre in ein paar Monaten eine 404 mitten in einer Übernahme. discover_pve_iso() { _listing="$(http_get "${CLUPILOT_ISO_MIRROR}/" || true)" [ -n "$_listing" ] || return 1 printf '%s' "$_listing" \ | grep -oE 'proxmox-ve_9[0-9.\-]*\.iso' \ | sort -u -V \ | tail -1 } # Lädt die ISO und prüft ihre SHA256 gegen die Liste des Anbieters. # # Ohne diese Prüfung startet die Maschine ein Betriebssystem, das unterwegs # irgendetwas geworden sein kann. Eine abgebrochene Übertragung reicht dafür # schon; Bösartigkeit braucht es gar nicht. fetch_proxmox_iso() { _name="$1" _target="$2" log "lade ${_name}" if ! http_download "${CLUPILOT_ISO_MIRROR}/${_name}" "$_target"; then return 1 fi _sums="$(http_get "${CLUPILOT_ISO_MIRROR}/SHA256SUMS" || true)" if [ -z "$_sums" ]; then log 'SHA256SUMS nicht abrufbar' return 1 fi _expected="$(printf '%s\n' "$_sums" | awk -v n="$_name" '$2 == n || $2 == "*" n { print $1; exit }')" if [ -z "$_expected" ]; then log "keine Prüfsumme für ${_name} in SHA256SUMS" return 1 fi _actual="$(sha256sum "$_target" | awk '{ print $1 }')" if [ "$_actual" != "$_expected" ]; then log "Prüfsumme stimmt nicht: erwartet ${_expected}, bekommen ${_actual}" rm -f "$_target" return 1 fi log "Prüfsumme stimmt (${_expected})" return 0 } # --------------------------------------------------------------------------- # Der Wiederanlauf nach dem Neustart # --------------------------------------------------------------------------- # # `[first-boot]` mit `source = "from-iso"` nimmt GENAU EINE ausführbare Datei # mit. Also trägt diese eine Datei alles: das Skript samt `lib/`, die # Fortschrittsdatei und die Aufrufargumente — als Archiv in base64 im eigenen # Rumpf. # # Das löst zugleich die Falle, an der „dieselbe Fortschrittsdatei" sonst # scheitert: das Rettungssystem läuft im Arbeitsspeicher, sein Wurzelverzeichnis # ist nach dem Neustart weg. Über die ISO kommt der Stand mit, ohne dass jemand # den frisch angelegten ZFS-Pool aus dem Rettungssystem heraus einhängen müsste. # # Der WireGuard-Schlüssel liegt damit in der ISO. Die ISO liegt im # Arbeitsspeicher des Rettungssystems und ist nach dem Neustart fort; der # Schlüssel selbst wird in Task 9 ohnehin gegen einen frischen getauscht. Die # entpackten Argumente bekommen 0600 in einem 0700-Verzeichnis. build_first_boot_shim() { _shim="$1" _payload="${CLUPILOT_WORK_DIR}/payload.tar.gz" # Argumente für den wiederaufgenommenen Lauf festhalten. _args_file="${CLUPILOT_STATE_DIR}/arguments" umask 077 cat > "$_args_file" < ` schriebe `bootstrap/…` und `clupilot/…` hinein # — ausgepackt nach `/` ergäbe das `/bootstrap` und `/clupilot`, und der Hook # liefe ins Leere. _stage="${CLUPILOT_WORK_DIR}/stage" rm -rf "$_stage" mkdir -p "${_stage}/opt/clupilot" "${_stage}/var/lib" cp -a "$CLUPILOT_BOOTSTRAP_DIR" "${_stage}/opt/clupilot/bootstrap" cp -a "$CLUPILOT_STATE_DIR" "${_stage}/var/lib/clupilot" tar czf "$_payload" -C "$_stage" opt var rm -rf "$_stage" cat > "$_shim" <<'HEADER' #!/bin/sh # Von CluPilots Bootstrap erzeugt. Läuft einmal, beim ersten Hochfahren des # frisch installierten Hosts, und nimmt den Lauf dort wieder auf, wo der # Neustart ihn unterbrochen hat. set -eu mkdir -p /opt/clupilot /var/lib sed -n '/^__NUTZLAST__$/,$p' "$0" | tail -n +2 | base64 -d | tar xzf - -C / chmod 700 /var/lib/clupilot [ -f /var/lib/clupilot/arguments ] && chmod 600 /var/lib/clupilot/arguments exec /opt/clupilot/bootstrap/clupilot-bootstrap.sh --resume HEADER printf '__NUTZLAST__\n' >> "$_shim" base64 "$_payload" >> "$_shim" chmod 755 "$_shim" } # --------------------------------------------------------------------------- # Die Antwortdatei # --------------------------------------------------------------------------- write_answer_file() { _file="$1" _disks="$2" _raid="$3" # Als TOML-Liste: ["nvme0n1", "nvme1n1"] _disk_list="$(printf '%s' "$_disks" | awk '{ for (i = 1; i <= NF; i++) printf "%s\"%s\"", (i > 1 ? ", " : ""), $i }')" # Wird erzeugt und weggeworfen. Niemand braucht es: die Plattform meldet # sich nicht mehr per SSH an (Spec §6), und eine halb installierte Maschine # wird neu aufgesetzt statt nachgebessert. Ein aufbewahrtes Kennwort wäre # nur eine weitere Stelle, an der ein Geheimnis liegt. _root_password="$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')" _ssh_keys='' if [ -n "${CLUPILOT_ROOT_SSH_KEY:-}" ]; then _ssh_keys="root-ssh-keys = [\"${CLUPILOT_ROOT_SSH_KEY}\"]" fi cat > "$_file" < "${CLUPILOT_WORK_DIR}/qemu.log" 2>&1 _rc=$? if [ "$_rc" -eq 124 ]; then log "QEMU nach ${CLUPILOT_INSTALL_TIMEOUT} s abgebrochen — der Installer ist nicht fertig geworden" return 1 fi if [ "$_rc" -ne 0 ]; then log "QEMU endete mit ${_rc}; siehe ${CLUPILOT_WORK_DIR}/qemu.log" return 1 fi return 0 }