CluPilotCloud/deploy/bootstrap/lib/proxmox.sh

496 lines
18 KiB
Bash

# 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 <codename> — 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" <<EOF
# Written by CluPilot's bootstrap script.
# The suite is derived from /etc/os-release on this host, not assumed.
Types: deb
URIs: http://download.proxmox.com/debian/pve
Suites: ${_suite}
Components: pve-no-subscription
Architectures: amd64
Signed-By: ${_keyring_path}
EOF
}
# ---------------------------------------------------------------------------
# Was diese Maschine gerade ist: Bootmodus, Netz, Platten
# ---------------------------------------------------------------------------
detect_boot_mode() {
if [ -d /sys/firmware/efi ]; then
printf 'uefi'
else
printf 'bios'
fi
}
# Die Schnittstelle, über die die Vorgabe-Route geht. Nicht „die erste, die
# nicht lo heißt": eine Maschine mit zwei Karten hat oft eine angeschlossene und
# eine nicht.
detect_primary_interface() {
ip -4 route show default 2>/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.
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
_suite="${_release%%|*}"
_rest="${_release#*|}"
_key_url="${_rest%%|*}"
_keyring="${_rest##*|}"
_keyring_path="/etc/apt/keyrings/${_keyring}"
mkdir -p /etc/apt/keyrings
if ! http_download "$_key_url" "$_keyring_path"; then
log "Proxmox-Release-Schlüssel für ${_codename} nicht ladbar: ${_key_url}"
return 1
fi
chmod 644 "$_keyring_path"
write_proxmox_sources "$_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" <<EOF
CLUPILOT_CODE='${CLUPILOT_CODE}'
CLUPILOT_WG_PRIVATE='${CLUPILOT_WG_PRIVATE}'
CLUPILOT_WG_IP='${CLUPILOT_WG_IP}'
CLUPILOT_HUB_PUBKEY='${CLUPILOT_HUB_PUBKEY}'
CLUPILOT_HUB_ENDPOINT='${CLUPILOT_HUB_ENDPOINT}'
CLUPILOT_API='${CLUPILOT_API}'
EOF
chmod 600 "$_args_file"
# Laut statt still: läge das Arbeitsverzeichnis im Zustandsverzeichnis,
# kopierte der nächste Schritt ein Verzeichnis in sich selbst und die ISO
# landete im Hook. Beides wäre erst auf echter Hardware aufgefallen.
case "$CLUPILOT_WORK_DIR" in
"$CLUPILOT_STATE_DIR"|"$CLUPILOT_STATE_DIR"/*)
log "Arbeitsverzeichnis ${CLUPILOT_WORK_DIR} liegt im Zustandsverzeichnis ${CLUPILOT_STATE_DIR} — das darf nicht sein"
return 1
;;
esac
# Über ein Staging-Verzeichnis, damit im Archiv die ZIELpfade stehen. Ein
# `tar -C <elternteil> <name>` 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" <<EOF
# Von CluPilots Bootstrap erzeugt. Unbeaufsichtigte Installation nach
# https://pve.proxmox.com/wiki/Automated_Installation
[global]
keyboard = "de"
country = "de"
fqdn = "${CLUPILOT_FQDN}"
mailto = "root@localhost"
timezone = "Europe/Vienna"
root-password = "${_root_password}"
${_ssh_keys}
reboot-on-error = false
[network]
source = "from-answer"
cidr = "${CLUPILOT_NET_CIDR}"
gateway = "${CLUPILOT_NET_GATEWAY}"
dns = "${CLUPILOT_NET_DNS}"
filter.ID_NET_NAME_MAC = "*${CLUPILOT_NET_MAC}"
[disk-setup]
filesystem = "zfs"
disk-list = [${_disk_list}]
[disk-setup.zfs]
raid = "${_raid}"
# Die Vorgabe nimmt sich die Hälfte des Arbeitsspeichers. Den braucht ein Host,
# der Kunden trägt, für die Gäste.
arc-max = ${CLUPILOT_ZFS_ARC_MAX_MB}
[first-boot]
source = "from-iso"
# Nach dem Hochfahren, wenn das Netz steht — vorher könnte der Lauf CluPilot
# nicht erreichen und bliebe an seinem eigenen Melden hängen.
ordering = "fully-up"
EOF
}
# ---------------------------------------------------------------------------
# Der Lauf unter QEMU
# ---------------------------------------------------------------------------
# Ein Viertel des Arbeitsspeichers für den Installer, mindestens 2 GB, höchstens
# 8. Mehr bringt nichts, weniger lässt den Installer bei ZFS ins Straucheln
# kommen.
installer_memory_mb() {
_total_kb="$(awk '/^MemTotal:/ { print $2; exit }' /proc/meminfo)"
_mb=$((_total_kb / 1024 / 4))
[ "$_mb" -lt 2048 ] && _mb=2048
[ "$_mb" -gt 8192 ] && _mb=8192
printf '%s' "$_mb"
}
installer_cpus() {
_cpus="$(nproc 2>/dev/null || echo 2)"
[ "$_cpus" -gt 8 ] && _cpus=8
[ "$_cpus" -lt 1 ] && _cpus=1
printf '%s' "$_cpus"
}
# Startet die vorbereitete ISO gegen die echten Platten.
#
# `-no-reboot`: der Installer startet am Ende neu, und QEMU beendet sich dabei.
# Genau daran erkennt dieses Skript, dass er fertig ist — ohne im Gast
# nachzusehen, was von außen nicht ginge.
#
# `-vnc 127.0.0.1:0` ist der Notausgang für den Fehlerfall: bleibt der Installer
# doch einmal in einer Maske stehen, kann der Betreiber über einen SSH-Tunnel ins
# Rettungssystem nachsehen, was auf dem Bildschirm steht. Nur auf localhost,
# damit daraus kein offener Dienst wird.
run_installer_under_qemu() {
_iso="$1"
_disks="$2"
_boot_mode="$3"
_drives=''
for _disk in $_disks; do
_drives="${_drives} -drive file=/dev/${_disk},format=raw,media=disk,if=virtio,cache=none"
done
_firmware=''
if [ "$_boot_mode" = 'uefi' ]; then
for _ovmf in /usr/share/OVMF/OVMF_CODE.fd /usr/share/ovmf/OVMF.fd /usr/share/qemu/OVMF.fd; do
if [ -f "$_ovmf" ]; then
_firmware="-bios ${_ovmf}"
break
fi
done
if [ -z "$_firmware" ]; then
log 'UEFI-Maschine, aber keine OVMF-Firmware gefunden (Paket ovmf)'
return 1
fi
fi
log "QEMU startet: ${_boot_mode}, $(installer_memory_mb) MB, $(installer_cpus) Kerne, Platten: ${_disks}"
# shellcheck disable=SC2086
timeout "$CLUPILOT_INSTALL_TIMEOUT" qemu-system-x86_64 \
-enable-kvm \
-cpu host \
-smp "$(installer_cpus)" \
-m "$(installer_memory_mb)" \
${_firmware} \
-cdrom "$_iso" \
-boot d \
${_drives} \
-netdev user,id=net0 -device virtio-net-pci,netdev=net0 \
-display none \
-vnc 127.0.0.1:0 \
-no-reboot \
> "${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
}