Write the image and survive the first reboot

The official Proxmox ISO, an answer file baked into it, and QEMU running it
against the physical disks from the rescue system. Options were looked up
rather than recalled: prepare-iso takes --fetch-from iso, --answer-file and
--on-first-boot, and it names its own output, so the finished ISO is found by
glob afterwards instead of being handed an --output flag I would have invented.

validate-answer runs BEFORE the ISO is baked. An answer file with a typo drops
the installer into interactive mode, where it then hangs invisibly inside QEMU
until the hour runs out — the failure would arrive as a timeout and say nothing
about the cause.

The codename table comes over verbatim from InstallProxmoxVe.php, which the
platform plan deletes. It is used here to install the assistant into the RESCUE
system, so it keys off the rescue system's own codename. An unknown one aborts;
not "trixie is newest, so trixie", because the next Debian will be unknown too
and by then nobody is watching.

The ISO is checksummed against the mirror's SHA256SUMS. Booting an operating
system that could have become anything in transit is not a risk worth taking,
and a truncated download is enough to cause it — malice is not required.

The reboot carry-across is the gap this plan was amended for, and [first-boot]
takes exactly one executable. So that one file carries everything: the script,
its lib/, the progress file and the arguments, as a base64 archive in its own
body. That also avoids importing the fresh ZFS pool from the rescue system to
copy files into it.

Two bugs found by running it rather than reading it. The staging copy put the
work directory inside the state directory, so cp refused to copy a directory
into itself — and had it not refused, the gigabyte ISO would have been base64'd
into the hook that gets baked into that same ISO. The fix is not a better
exclude: the two directories have opposite lifetimes, so they now live in
different places, and a guard refuses loudly if anyone points them at each other
again. Structure beats a rule someone has to remember.

Verified without hardware: both files pass sh and dash. The generated answer
file parses as TOML with the expected disk-list, zfs.raid, zfs.arc-max,
first-boot and network.filter, and a 64-character root password. The codename
table accepts bookworm and trixie and refuses bullseye and forky. The shim comes
out at 26 KB with a 3 MB dummy ISO sitting in the work directory, proving it
stayed out. Run in a fresh debian:13-slim container it unpacks to
/opt/clupilot/bootstrap and /var/lib/clupilot, restores the progress file with
its original timestamp, has 700 on the directory and 600 on the arguments that
hold the WireGuard key, skips rescue_checked as already done, and fails cleanly
on the missing qemu. Step 2 stays unticked: a container is not a rescue system,
and nothing here has yet written to a disk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/host-bootstrap
nexxo 2026-07-30 19:57:29 +02:00
parent 0b26e45147
commit 0f563545fd
4 changed files with 800 additions and 37 deletions

View File

@ -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 <url> CluPilot über die TUNNEL-Adresse, ohne Schrägstrich
am Ende, z. B. http://10.66.0.1
Freiwillig:
--root-ssh-key <schlüssel> ö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 "$@"

View File

@ -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 <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
}

View File

@ -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 <url> — 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 <url> <ziel> — 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
}

View File

@ -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)