diff --git a/deploy/bootstrap/clupilot-bootstrap.sh b/deploy/bootstrap/clupilot-bootstrap.sh index 9a3c7f1..a59bee6 100755 --- a/deploy/bootstrap/clupilot-bootstrap.sh +++ b/deploy/bootstrap/clupilot-bootstrap.sh @@ -21,6 +21,16 @@ CLUPILOT_HUB_PUBKEY='' CLUPILOT_HUB_ENDPOINT='' CLUPILOT_API='' +# Freiwillig. Ohne diese Angabe gibt es keinen Weg per SSH auf die Maschine, und +# das ist die Vorgabe: die Plattform meldet sich nicht mehr an (Spec §6). +CLUPILOT_ROOT_SSH_KEY='' + +# 1, wenn der `[first-boot]`-Hook uns nach dem Neustart wieder aufgenommen hat. +CLUPILOT_RESUMING=0 + +# Wird in `derive_fqdn` gesetzt; der Installer verlangt einen. +CLUPILOT_FQDN='' + # Wird von `run_section` gefüllt, wenn ein Abschnitt der Meldung etwas # mitzugeben hat, und danach wieder geleert. CLUPILOT_SECTION_NOTE='' @@ -52,6 +62,29 @@ CLUPILOT_MIN_DISK_GB=100 # Zertifikaten redet und nicht von der Uhrzeit. CLUPILOT_MAX_CLOCK_DRIFT=3600 +# Wo die ISO gebaut wird — AUSDRÜCKLICH nicht im Zustandsverzeichnis. +# +# Die beiden haben entgegengesetzte Lebensdauern: `/var/lib/clupilot` muss den +# Neustart überleben und wandert dafür in den first-boot-Hook; dieses hier darf +# das auf keinen Fall, denn hier liegt die ISO. Läge es darin, packte der Hook +# ein Gigabyte base64 in sich selbst — und würde in genau die ISO gebacken, die +# er enthält. +# +# Das war kein gedachter Fall: die erste Fassung legte `work/` unter das +# Zustandsverzeichnis, und `cp` weigerte sich, ein Verzeichnis in sich selbst zu +# kopieren. Getrennte Orte machen aus der Regel, die man sich merken muss, eine +# Eigenschaft, die man nicht verletzen kann. +CLUPILOT_WORK_DIR="${CLUPILOT_WORK_DIR:-/var/tmp/clupilot-work}" +CLUPILOT_ISO_MIRROR="${CLUPILOT_ISO_MIRROR:-https://enterprise.proxmox.com/iso}" + +# ZFS nimmt sich sonst die Hälfte des Arbeitsspeichers für den Lesecache. Ein +# Achtel, mindestens 2 GB — der Rest gehört den Gästen. +CLUPILOT_ZFS_ARC_MAX_MB="${CLUPILOT_ZFS_ARC_MAX_MB:-2048}" + +# Eine Stunde. Eine Proxmox-Installation auf ZFS braucht Minuten, keine Stunde; +# wer diese Grenze reißt, hängt und läuft nicht langsam. +CLUPILOT_INSTALL_TIMEOUT="${CLUPILOT_INSTALL_TIMEOUT:-3600}" + usage() { cat <<'TEXT' clupilot-bootstrap.sh — macht aus einer Maschine im Rettungssystem einen @@ -65,6 +98,14 @@ fertigen Proxmox-Host. --api CluPilot über die TUNNEL-Adresse, ohne Schrägstrich am Ende, z. B. http://10.66.0.1 +Freiwillig: + + --root-ssh-key öffentlicher Schlüssel für root. Ohne ihn gibt es + keinen SSH-Zugang auf den fertigen Host. + --resume nimmt einen unterbrochenen Lauf wieder auf. Setzt + der first-boot-Hook nach dem Neustart selbst; von + Hand braucht man das nicht. + Diese Zeile wird nicht abgetippt. Der Adminbereich zeigt sie fertig zum Kopieren, wenn ein Host angelegt wird. TEXT @@ -93,11 +134,23 @@ parse_arguments() { --hub-pubkey) CLUPILOT_HUB_PUBKEY="${2:-}"; shift 2 ;; --hub-endpoint) CLUPILOT_HUB_ENDPOINT="${2:-}"; shift 2 ;; --api) CLUPILOT_API="${2:-}"; shift 2 ;; + --root-ssh-key) CLUPILOT_ROOT_SSH_KEY="${2:-}"; shift 2 ;; + --resume) CLUPILOT_RESUMING=1; shift ;; -h|--help) usage; exit 0 ;; *) usage >&2; die "unbekannte Angabe: $1" ;; esac done + # Der wiederaufgenommene Lauf nach dem Neustart bekommt keine Befehlszeile + # mehr — der `[first-boot]`-Hook ruft nur `--resume`. Die Werte stehen in + # der Datei, die vor dem Neustart mit in die ISO gegangen ist. + if [ "$CLUPILOT_RESUMING" = '1' ]; then + _saved="${CLUPILOT_STATE_DIR:-/var/lib/clupilot}/arguments" + [ -r "$_saved" ] || die "--resume, aber ${_saved} ist nicht lesbar. Der Lauf lässt sich nicht fortsetzen; die Maschine wird neu aufgesetzt (Runbook)." + # shellcheck disable=SC1090 + . "$_saved" + fi + # Alle sechs sind Pflicht. Es gibt keine sinnvolle Vorgabe für einen davon, # und ein fehlender fällt sonst erst mitten in einer Installation auf, die # die Platte schon überschrieben hat. @@ -114,31 +167,71 @@ parse_arguments() { CLUPILOT_API="${CLUPILOT_API%/}" } -# Findet `lib/` neben dem Skript. +# Findet das Verzeichnis, in dem dieses Skript samt `lib/` liegt. # -# OFFEN, und zwar außerhalb dieses Skripts: der Adminbereich zeigt laut -# Plattform-Plan Task 7 eine Zeile mit `curl` darin, aber weder Spec noch Plan -# sagen, WAS geladen wird. Ein `curl … | sh` kann keine `lib/` haben; der Plan -# verlangt aber getrennte Dateien, damit `network.sh` und `template.sh` -# unabhängig wiederholbar bleiben. Beides zugleich geht nur, wenn die Zeile ein -# Archiv holt und auspackt. Bis das entschieden ist, sucht das Skript seine -# Bibliothek neben sich und fällt auf den Ort zurück, an den Task 3 sie kopiert. -resolve_lib_dir() { - _self_dir="$(dirname -- "$0")" +# Ausgeliefert wird als Archiv nach `/opt/clupilot/bootstrap`; der Weg über `$0` +# steht davor, damit sich das Skript auch aus einem ausgecheckten Repo heraus +# starten lässt, ohne es vorher zu installieren. +resolve_bootstrap_dir() { + _self_dir="$(cd -- "$(dirname -- "$0")" 2>/dev/null && pwd)" - if [ -f "${_self_dir}/lib/report.sh" ]; then - printf '%s' "${_self_dir}/lib" + if [ -n "$_self_dir" ] && [ -f "${_self_dir}/lib/report.sh" ]; then + printf '%s' "$_self_dir" return 0 fi if [ -f '/opt/clupilot/bootstrap/lib/report.sh' ]; then - printf '%s' '/opt/clupilot/bootstrap/lib' + printf '%s' '/opt/clupilot/bootstrap' return 0 fi die 'lib/report.sh nicht gefunden — das Skript wurde ohne seine Bibliothek kopiert' } +# Läuft dieser Lauf noch im Rettungssystem, oder schon auf dem installierten +# Host? +# +# Dieselbe Unterscheidung wie in `check_is_rescue_system`, nur als schlichte +# Frage — und die eine Stelle, an der `section_rebooted` erkennt, dass der +# Neustart hinter ihm liegt statt vor ihm. +running_in_rescue_system() { + _fstype="$(root_field type)" + _device="$(root_field dev)" + + case "$_fstype" in + tmpfs|ramfs|rootfs|overlay|overlayfs|squashfs|aufs) return 0 ;; + esac + + case "$_device" in + /dev/ram*|/dev/nfs|none) return 0 ;; + esac + + return 1 +} + +# Der Installer verlangt einen FQDN, und die Befehlszeile bringt keinen mit. +# +# Erst die Rückwärtsauflösung der öffentlichen Adresse — das ist der Name, den +# der Anbieter für diese Maschine führt. Sonst ein Name aus der Tunneladresse, +# der eindeutig ist und niemanden in die Irre führt. In Task 9 ersetzt CluPilot +# ihn ohnehin durch den, den es selbst führt. +derive_fqdn() { + _ip="$(ip -4 -o addr show scope global 2>/dev/null | awk '{ sub(/\/.*/, "", $4); print $4; exit }')" + + if [ -n "$_ip" ] && command -v getent >/dev/null 2>&1; then + _name="$(getent hosts "$_ip" 2>/dev/null | awk '{ print $2; exit }')" + case "$_name" in + *.*.*) + CLUPILOT_FQDN="$_name" + return 0 + ;; + esac + fi + + _suffix="$(printf '%s' "$CLUPILOT_WG_IP" | sed -e 's#/.*##' -e 's/\./-/g')" + CLUPILOT_FQDN="host-${_suffix}.clupilot.net" +} + # --------------------------------------------------------------------------- # Abschnitt 1: rescue_checked # --------------------------------------------------------------------------- @@ -211,20 +304,9 @@ largest_disk_bytes() { # Wurzelverzeichnis liegt auf tmpfs, einem Overlay oder einer RAM-Platte. Ein # installiertes System hat dort eine echte Partition. check_is_rescue_system() { - _fstype="$(root_field type)" - _device="$(root_field dev)" - - case "${_fstype}" in - tmpfs|ramfs|rootfs|overlay|overlayfs|squashfs|aufs) ;; - *) - case "${_device}" in - /dev/ram*|/dev/nfs|none) ;; - *) - refuse "Das Wurzelverzeichnis liegt auf ${_device} (${_fstype}), also auf einer echten Partition. Ein Rettungssystem läuft aus dem Arbeitsspeicher. Diese Maschine sieht aus wie ein installiertes System, und dieses Skript überschreibt Platten — es macht hier nicht weiter. Rettungssystem im Kundenbereich des Anbieters starten und neu einfügen." - ;; - esac - ;; - esac + if ! running_in_rescue_system; then + refuse "Das Wurzelverzeichnis liegt auf $(root_field dev) ($(root_field type)), also auf einer echten Partition. Ein Rettungssystem läuft aus dem Arbeitsspeicher. Diese Maschine sieht aus wie ein installiertes System, und dieses Skript überschreibt Platten — es macht hier nicht weiter. Rettungssystem im Kundenbereich des Anbieters starten und neu einfügen." + fi # Ein laufender Hypervisor ist die eine Maschine, die auf keinen Fall # angefasst werden darf — auf ihr liegen Kunden. @@ -341,15 +423,145 @@ section_rescue_checked() { return 0 } +# --------------------------------------------------------------------------- +# Abschnitt 2: debian_installed — das Grundsystem auf die Platte +# --------------------------------------------------------------------------- +# +# „debian_installed" heißt hier **Grundsystem geschrieben**. Es entsteht kein +# Debian, das nachher jemand zu Proxmox umbaut: die offizielle Proxmox-ISO wird +# mit einer Antwortdatei versehen und unter QEMU gegen die echten Platten +# gestartet. Der Schlüssel bleibt trotzdem, weil er die einzige Absprache mit +# dem Plattform-Plan ist — und weil Proxmox VE Debian mit PVE-Kernel ist. +section_debian_installed() { + mkdir -p "$CLUPILOT_WORK_DIR" + + for _tool in qemu-system-x86_64 sha256sum timeout tar base64; do + command -v "$_tool" >/dev/null 2>&1 || { + CLUPILOT_SECTION_NOTE="${_tool} fehlt im Rettungssystem" + return 1 + } + done + + if ! detect_network; then + CLUPILOT_SECTION_NOTE='Netzkonfiguration des Rettungssystems nicht ermittelbar (Adresse, Gateway oder MAC fehlt)' + return 1 + fi + + _disks="$(detect_install_disks)" + if [ -z "$_disks" ]; then + CLUPILOT_SECTION_NOTE='keine physische Platte gefunden' + return 1 + fi + _count="$(printf '%s' "$_disks" | wc -w | tr -d ' ')" + _raid="$(zfs_raid_for "$_count")" + _boot_mode="$(detect_boot_mode)" + + derive_fqdn + log "Ziel: ${CLUPILOT_FQDN}, ${_boot_mode}, ZFS ${_raid} über ${_count} Platte(n): ${_disks}" + + if ! ensure_autoinstall_assistant; then + CLUPILOT_SECTION_NOTE='proxmox-auto-install-assistant nicht installierbar' + return 1 + fi + + _iso_name="$(discover_pve_iso)" + if [ -z "$_iso_name" ]; then + CLUPILOT_SECTION_NOTE="keine PVE-9-ISO unter ${CLUPILOT_ISO_MIRROR} gefunden" + return 1 + fi + + _iso="${CLUPILOT_WORK_DIR}/${_iso_name}" + if [ ! -f "$_iso" ] && ! fetch_proxmox_iso "$_iso_name" "$_iso"; then + CLUPILOT_SECTION_NOTE="${_iso_name} nicht ladbar oder Prüfsumme falsch" + return 1 + fi + + build_first_boot_shim "${CLUPILOT_WORK_DIR}/first-boot.sh" + write_answer_file "${CLUPILOT_WORK_DIR}/answer.toml" "$_disks" "$_raid" + + # Vor dem Backen prüfen. Eine Antwortdatei mit einem Tippfehler lässt den + # Installer in den interaktiven Modus fallen — und dort hängt er dann + # unsichtbar in QEMU, bis die Stunde abgelaufen ist. + if ! proxmox-auto-install-assistant validate-answer "${CLUPILOT_WORK_DIR}/answer.toml" >> "${CLUPILOT_WORK_DIR}/assistant.log" 2>&1; then + CLUPILOT_SECTION_NOTE="answer.toml abgelehnt; siehe ${CLUPILOT_WORK_DIR}/assistant.log" + return 1 + fi + + # Der Assistent legt die fertige ISO neben die Quelle und benennt sie selbst. + # Deshalb wird sie danach gesucht statt vorher benannt — ein erfundener + # `--output`-Schalter wäre die Sorte Annahme, die genau einmal auffällt. + rm -f "${CLUPILOT_WORK_DIR}"/*auto*.iso + if ! (cd "$CLUPILOT_WORK_DIR" && proxmox-auto-install-assistant prepare-iso "$_iso" \ + --fetch-from iso \ + --answer-file "${CLUPILOT_WORK_DIR}/answer.toml" \ + --on-first-boot "${CLUPILOT_WORK_DIR}/first-boot.sh") >> "${CLUPILOT_WORK_DIR}/assistant.log" 2>&1; then + CLUPILOT_SECTION_NOTE="prepare-iso fehlgeschlagen; siehe ${CLUPILOT_WORK_DIR}/assistant.log" + return 1 + fi + + _prepared="$(ls -1 "${CLUPILOT_WORK_DIR}"/*auto*.iso 2>/dev/null | head -1)" + if [ -z "$_prepared" ]; then + CLUPILOT_SECTION_NOTE='prepare-iso meldete Erfolg, hat aber keine ISO hinterlassen' + return 1 + fi + + if ! run_installer_under_qemu "$_prepared" "$_disks" "$_boot_mode"; then + CLUPILOT_SECTION_NOTE="Installer nicht durchgelaufen; siehe ${CLUPILOT_WORK_DIR}/qemu.log" + return 1 + fi + + CLUPILOT_SECTION_NOTE="Proxmox aus ${_iso_name} auf ZFS ${_raid} über ${_count} Platte(n)" + return 0 +} + +# --------------------------------------------------------------------------- +# Abschnitt 3: rebooted — die einzige unumkehrbare Stelle +# --------------------------------------------------------------------------- +# +# Dieser Abschnitt läuft ZWEIMAL, auf zwei verschiedenen Systemen. Im +# Rettungssystem startet er neu und kommt nie zurück; nach dem Hochfahren nimmt +# der `[first-boot]`-Hook den Lauf wieder auf, und dann stellt derselbe +# Abschnitt fest, dass er hinter sich liegt, und meldet ihn. +# +# Deshalb gibt es hier auch kein `report rebooted done` vor dem Neustart: eine +# Meldung, die einen Neustart ankündigt, der dann nicht klappt, ist schlimmer +# als gar keine. +section_rebooted() { + if ! running_in_rescue_system; then + CLUPILOT_SECTION_NOTE="wieder da auf $(uname -r)" + return 0 + fi + + # Alles, was noch liegt, ein letztes Mal versuchen. Ab hier ist diese + # Fortschrittsdatei nur noch über die ISO erreichbar. + flush_reports + + log 'Neustart aus dem Rettungssystem. Der first-boot-Hook nimmt den Lauf wieder auf.' + sync + (sleep 5; systemctl reboot 2>/dev/null || reboot) & + + # Nicht mit `exit 0` enden: das Skript ist an dieser Stelle nicht fertig, + # es gibt nur die Maschine ab. Der Rückgabewert wäre eine Aussage über + # etwas, das erst nach dem Hochfahren feststeht. + sleep 120 + die 'Neustart wurde angestoßen, aber die Maschine läuft nach zwei Minuten noch. Über die Anbieterkonsole nachsehen.' +} + main() { parse_arguments "$@" - _lib="$(resolve_lib_dir)" + CLUPILOT_BOOTSTRAP_DIR="$(resolve_bootstrap_dir)" # shellcheck source=lib/report.sh - . "${_lib}/report.sh" + . "${CLUPILOT_BOOTSTRAP_DIR}/lib/report.sh" + # shellcheck source=lib/proxmox.sh + . "${CLUPILOT_BOOTSTRAP_DIR}/lib/proxmox.sh" init_reporting - log "Bootstrap beginnt — Tunnel-Adresse ${CLUPILOT_WG_IP}, CluPilot unter ${CLUPILOT_API}" + if [ "$CLUPILOT_RESUMING" = '1' ]; then + log "Lauf wieder aufgenommen nach dem Neustart — $(uname -r)" + else + log "Bootstrap beginnt — Tunnel-Adresse ${CLUPILOT_WG_IP}, CluPilot unter ${CLUPILOT_API}" + fi # Vor jedem Abschnitt: was liegengeblieben ist, geht zuerst raus. Nach dem # Tunnelbeitritt ist das der Aufruf, der die ganze Vorgeschichte nachreicht. @@ -361,8 +573,15 @@ main() { die 'Abbruch nach der Eingangsprüfung. Es wurde nichts geschrieben — die Platte ist unangetastet.' fi - # run_section debian_installed section_debian_installed # Task 3 - # run_section rebooted section_rebooted # Task 3 + if ! run_section debian_installed section_debian_installed; then + die 'Abbruch: das Grundsystem ist nicht auf die Platte gekommen. Die Maschine wird neu aufgesetzt, nicht nachgebessert (Runbook).' + fi + + # Kommt im Rettungssystem nicht zurück — die Maschine startet hier neu. + if ! run_section rebooted section_rebooted; then + die 'Abbruch beim Neustart.' + fi + # run_section proxmox_installed section_proxmox_installed # Task 4 # run_section network_bridged section_network_bridged # Task 5 # run_section wireguard_joined section_wireguard_joined # Task 6 @@ -370,7 +589,7 @@ main() { # run_section template_built section_template_built # Task 8 # run_section registered section_registered # Task 9 - log 'Eingangsprüfung bestanden. Die Abschnitte ab Task 3 sind noch nicht eingehängt.' + log 'Proxmox läuft. Die Abschnitte ab Task 4 sind noch nicht eingehängt.' } main "$@" diff --git a/deploy/bootstrap/lib/proxmox.sh b/deploy/bootstrap/lib/proxmox.sh new file mode 100644 index 0000000..6bdef7a --- /dev/null +++ b/deploy/bootstrap/lib/proxmox.sh @@ -0,0 +1,495 @@ +# 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. +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" < ` 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 +} diff --git a/deploy/bootstrap/lib/report.sh b/deploy/bootstrap/lib/report.sh index 371cbb9..a2cd7ad 100644 --- a/deploy/bootstrap/lib/report.sh +++ b/deploy/bootstrap/lib/report.sh @@ -1,6 +1,12 @@ # shellcheck shell=sh # -# Melden und Nachreichen. Diese Datei kennt als Einzige den Weg zu CluPilot. +# Melden und Nachreichen — und die HTTP-Grundlage für alle anderen Abschnitte. +# +# Diese Datei kennt als Einzige den Weg zu CluPilot. Sie hält zusätzlich +# `http_get` und `http_download`, damit der Rückfall von curl auf wget an EINER +# Stelle steht: ein Rettungssystem bringt mal das eine mit und mal das andere, +# und diese Fallunterscheidung fünfmal zu schreiben heißt, sie viermal falsch zu +# schreiben. # # Zwei Regeln, aus denen sich alles andere ergibt: # @@ -213,3 +219,46 @@ post_json() { return 1 } + +# http_get — Rumpf nach stdout, 0 bei Erfolg. +# +# Für kleine Antworten: Verzeichnislisten, Prüfsummendateien. Nicht für ISOs; +# dafür gibt es `http_download`, das nicht durch den Speicher geht. +http_get() { + if command -v curl >/dev/null 2>&1; then + curl -sS -fL --connect-timeout 10 --max-time 120 "$1" 2>/dev/null + return $? + fi + + if command -v wget >/dev/null 2>&1; then + wget -q -O - --timeout=120 "$1" 2>/dev/null + return $? + fi + + return 1 +} + +# http_download — 0 bei Erfolg. +# +# Kein `-C -`/`--continue`: eine halb geladene ISO, die beim zweiten Lauf +# fortgesetzt wird, ergibt eine Datei mit richtiger Größe und falschem Inhalt, +# wenn sich die Quelle dazwischen geändert hat. Lieber ganz neu — die Prüfsumme +# fängt es zwar auch ab, aber erst nach dem Herunterladen. +http_download() { + _url="$1" + _target="$2" + + rm -f "$_target" + + if command -v curl >/dev/null 2>&1; then + curl -sS -fL --connect-timeout 10 --max-time 3600 -o "$_target" "$_url" 2>/dev/null + return $? + fi + + if command -v wget >/dev/null 2>&1; then + wget -q -O "$_target" --timeout=3600 "$_url" 2>/dev/null + return $? + fi + + return 1 +} diff --git a/docs/superpowers/plans/2026-07-30-host-uebernahme-bootstrap-skript.md b/docs/superpowers/plans/2026-07-30-host-uebernahme-bootstrap-skript.md index ffdb793..6136080 100644 --- a/docs/superpowers/plans/2026-07-30-host-uebernahme-bootstrap-skript.md +++ b/docs/superpowers/plans/2026-07-30-host-uebernahme-bootstrap-skript.md @@ -48,7 +48,7 @@ |---|---| | `deploy/bootstrap/clupilot-bootstrap.sh` | Das Skript. Ein Einstiegspunkt, Abschnitte als Funktionen. | | `deploy/bootstrap/lib/report.sh` | Melden und Nachreichen. Kennt als Einziges den Weg zu CluPilot. | -| `deploy/bootstrap/lib/proxmox.sh` | Abschnitte 4 und 5 (Installation, Brücke) | +| `deploy/bootstrap/lib/proxmox.sh` | Die Installation (`debian_installed`, `rebooted`) und die Paketquellen (`proxmox_installed`) | | `deploy/bootstrap/lib/network.sh` | Brücke mit Selbstrücknahme, nftables | | `deploy/bootstrap/lib/template.sh` | Die goldene Vorlage | | `deploy/bootstrap/assets/docker-compose.yml` | Die Compose-Datei, die in die Vorlage kommt | @@ -135,7 +135,7 @@ genau hier seine einzige Gelegenheit, es nicht zu tun. ### Task 3: `debian_installed` und `rebooted` -- [ ] **Step 1: Schreiben** +- [x] **Step 1: Schreiben** > **Korrigiert gegenüber der ersten Fassung.** Dort stand „Debian 13 ins > Zielsystem", und danach hätte Task 4 daraus einen Proxmox-Host gemacht. Der @@ -245,7 +245,7 @@ bewiesen, bevor neu gestartet wird, nicht danach gehofft. Neustart von allein weiterläuft. **Dazu: nach dem Hochfahren in `/var/lib/clupilot/progress.jsonl` nachsehen, dass die Zeilen von *vor* dem Neustart noch dort stehen, mit ihren ursprünglichen Zeitstempeln.** -- [ ] **Step 3: Committen.** `Write the image and survive the first reboot` +- [x] **Step 3: Committen.** `Write the image and survive the first reboot` (war `Write Debian and survive the first reboot`, bevor klar war, dass kein Debian-Zwischensystem entsteht)