diff --git a/deploy/bootstrap/clupilot-bootstrap.sh b/deploy/bootstrap/clupilot-bootstrap.sh index 11bab7f..9a3c7f1 100755 --- a/deploy/bootstrap/clupilot-bootstrap.sh +++ b/deploy/bootstrap/clupilot-bootstrap.sh @@ -25,6 +25,33 @@ CLUPILOT_API='' # mitzugeben hat, und danach wieder geleert. CLUPILOT_SECTION_NOTE='' +# --------------------------------------------------------------------------- +# Was diese Maschine mindestens können muss +# --------------------------------------------------------------------------- +# +# Die Rechnung steht offen da, weil die Zahl selbst nichts beweist. Das Repo +# hält keine Untergrenze bereit — die Pläne liegen in der Datenbank, nicht in +# `config/provisioning.php` —, also wird hier gerechnet statt behauptet: +# +# goldene Vorlage (Debian-Cloud-Image + Docker + Nextcloud) ~20 GB +# Proxmox selbst, Auslagerung, Sicherungen ~20 GB +# ein kleinster Kunde, damit der Host überhaupt einen trägt ~50 GB +# ------------------------------------------------------------------ +# ~90 GB +# +# Aufgerundet auf 100. Das ist ausdrücklich KEINE Kapazitätsplanung: die macht +# `HostCapacity` mit `reserve_pct`, nachdem der Host registriert ist. Diese +# Prüfung weist nur die Maschine ab, auf der das Vorhaben von vornherein nicht +# aufgeht — eine 40-GB-Cloud-Instanz, auf der jemand versehentlich die Zeile +# eingefügt hat. +CLUPILOT_MIN_DISK_GB=100 + +# Über eine Stunde daneben, und es ist keine Gangungenauigkeit mehr, sondern +# eine Uhr, die nie gestellt wurde. Genau dann scheitert jede TLS-Prüfung — +# Paketquellen, Let's Encrypt, der eigene Rückweg — mit einer Meldung, die von +# Zertifikaten redet und nicht von der Uhrzeit. +CLUPILOT_MAX_CLOCK_DRIFT=3600 + usage() { cat <<'TEXT' clupilot-bootstrap.sh — macht aus einer Maschine im Rettungssystem einen @@ -112,6 +139,208 @@ resolve_lib_dir() { die 'lib/report.sh nicht gefunden — das Skript wurde ohne seine Bibliothek kopiert' } +# --------------------------------------------------------------------------- +# Abschnitt 1: rescue_checked +# --------------------------------------------------------------------------- +# +# Läuft, BEVOR irgendetwas geschrieben wird. Das ist die einzige Gelegenheit +# dieses Skripts, eine Platte NICHT zu überschreiben — danach gibt es keine +# mehr. +# +# Alle Befunde werden gesammelt und zusammen gemeldet, statt beim ersten +# auszusteigen. Wer eine Maschine im Rettungssystem sitzen hat, will einmal +# lesen, was ihr fehlt, und nicht sechsmal von vorn anfangen. +# +# Eine unbekannte Lage führt zur Ablehnung, nicht zum Weitermachen. Es gibt +# absichtlich keinen Schalter, der die Prüfung übergeht: ein solcher Schalter +# wird genau einmal benutzt, und zwar an dem Abend, an dem er nicht hätte +# benutzt werden dürfen. Ein Rettungssystem, das hier nicht erkannt wird, gehört +# ins Runbook (Task 10) und nicht in eine Umgehung. + +CLUPILOT_REFUSALS='' +CLUPILOT_NOTES='' +# Bewusst ohne TLS und bewusst gegen eine Adresse, die es ohnehin gleich +# braucht. Überschreibbar für Netze, in denen deb.debian.org nicht erreichbar +# ist — und damit sich die Uhrprüfung gegen eine Antwort mit bekannter Uhrzeit +# durchspielen lässt. +CLUPILOT_PROBE_URL="${CLUPILOT_PROBE_URL:-http://deb.debian.org/}" + +refuse() { + CLUPILOT_REFUSALS="${CLUPILOT_REFUSALS} - $1 +" + return 0 +} + +note() { + CLUPILOT_NOTES="${CLUPILOT_NOTES}${CLUPILOT_NOTES:+; }$1" + return 0 +} + +root_field() { + awk -v want="$1" '$2 == "/" { print (want == "dev" ? $1 : $3); exit }' /proc/mounts +} + +# Größte physische Platte in Bytes. +# +# Aus `/sys/block` statt aus `lsblk`, weil das keine Abhängigkeit hat und in +# jedem Rettungssystem vorhanden ist. `size` zählt dort immer 512-Byte-Sektoren, +# unabhängig davon, mit welcher Sektorgröße die Platte selbst arbeitet — eine +# Vier-Kilobyte-Platte meldet hier trotzdem Vielfache von 512. +largest_disk_bytes() { + _max=0 + for _dev in /sys/block/*; do + _name="${_dev##*/}" + case "$_name" in + loop*|ram*|sr*|dm-*|md*|zram*|fd*) continue ;; + esac + [ -r "${_dev}/size" ] || continue + _sectors="$(cat "${_dev}/size" 2>/dev/null || echo 0)" + _bytes=$((_sectors * 512)) + if [ "$_bytes" -gt "$_max" ]; then + _max="$_bytes" + fi + done + printf '%s' "$_max" +} + +# Der teuerste denkbare Fehlgriff dieses Skripts: eine laufende Produktivmaschine +# für ein Rettungssystem halten und ihre Platte überschreiben. +# +# Deshalb wird ein POSITIVER Nachweis verlangt, nicht das Fehlen eines +# Gegenbeweises. Ein Rettungssystem läuft aus dem Arbeitsspeicher; sein +# 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 + + # Ein laufender Hypervisor ist die eine Maschine, die auf keinen Fall + # angefasst werden darf — auf ihr liegen Kunden. + if command -v pveversion >/dev/null 2>&1; then + refuse "Auf dieser Maschine läuft bereits Proxmox VE ($(pveversion 2>/dev/null | head -1)). Das ist ein Host im Betrieb, kein leeres Blech. Wenn er neu aufgesetzt werden soll, erst im Adminbereich entfernen, dann Rettungssystem starten." + fi + + # Im Rettungssystem sind die Zielplatten normalerweise nicht eingehängt. Ist + # doch eine da, hat entweder jemand von Hand nachgesehen — dann soll er + # aushängen — oder es ist doch das laufende System. + _mounted="$(awk '$1 ~ /^\/dev\/(sd|nvme|vd|hd|xvd)/ { printf "%s auf %s, ", $1, $2 }' /proc/mounts | sed -e 's/, $//')" + if [ -n "$_mounted" ]; then + refuse "Es sind Partitionen echter Platten eingehängt (${_mounted}). Solange die dort liegen, weiß dieses Skript nicht, ob es eine leere Maschine oder Ihre Daten vor sich hat. Aushängen und noch einmal einfügen." + fi +} + +# Ohne verschachtelte Virtualisierung startet kein Gast, und damit ist der ganze +# Host nutzlos — das fiele sonst erst beim ersten bezahlten Auftrag auf. +check_nested_virtualisation() { + _flags="$(grep -cE '(^|[[:space:]])(vmx|svm)([[:space:]]|$)' /proc/cpuinfo 2>/dev/null || echo 0)" + + if [ "$_flags" -eq 0 ]; then + refuse 'Die CPU meldet weder vmx noch svm in /proc/cpuinfo — auf dieser Maschine gibt es keine Hardware-Virtualisierung. Bei Cloud-Produkten (Hetzner CPX/CX, teils netcup) ist das normal und nicht abstellbar; es braucht eine dedizierte Maschine.' + return 0 + fi + + if [ ! -c /dev/kvm ]; then + refuse 'Die CPU kann Virtualisierung, aber /dev/kvm gibt es nicht. Entweder ist sie im BIOS abgeschaltet, oder das Rettungssystem hat das kvm-Modul nicht geladen (`modprobe kvm_intel` bzw. `kvm_amd`).' + fi +} + +check_disk_size() { + _bytes="$(largest_disk_bytes)" + _gb=$((_bytes / 1073741824)) + + if [ "$_gb" -eq 0 ]; then + refuse 'Unter /sys/block ist keine physische Platte zu finden. Ohne Platte gibt es nichts zu installieren.' + return 0 + fi + + if [ "$_gb" -lt "$CLUPILOT_MIN_DISK_GB" ]; then + refuse "Die größte Platte hat ${_gb} GB, gebraucht werden mindestens ${CLUPILOT_MIN_DISK_GB} GB (Vorlage, Proxmox selbst, ein kleinster Kunde). Diese Maschine trägt das Vorhaben nicht." + return 0 + fi + + note "größte Platte ${_gb} GB" +} + +# Netz und Uhr in einem Abruf: der `Date`-Kopf einer gewöhnlichen HTTP-Antwort +# beantwortet beides. Ausdrücklich ohne TLS — sonst prüfte eine falsche Uhr +# sich selbst und meldete einen Zertifikatsfehler. +check_network_and_clock() { + if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then + refuse 'Weder curl noch wget vorhanden. Ohne eines von beidem kann sich diese Maschine nie bei CluPilot melden.' + return 0 + fi + + _headers='' + if command -v curl >/dev/null 2>&1; then + _headers="$(curl -sS -I --connect-timeout 5 --max-time 15 "$CLUPILOT_PROBE_URL" 2>/dev/null || true)" + else + _headers="$(wget -q -S --timeout=15 --spider "$CLUPILOT_PROBE_URL" 2>&1 || true)" + fi + + if [ -z "$_headers" ]; then + refuse "Keine Antwort von ${CLUPILOT_PROBE_URL}. Ohne Netz kommt weder Debian noch Proxmox auf diese Maschine. Netz und Namensauflösung im Rettungssystem prüfen." + return 0 + fi + + _remote_date="$(printf '%s' "$_headers" | sed -n 's/^[[:space:]]*[Dd]ate:[[:space:]]*//p' | tr -d '\r' | head -1)" + if [ -z "$_remote_date" ]; then + note 'Uhr nicht geprüft (Antwort ohne Date-Kopf)' + return 0 + fi + + # `date -d` ist GNU-eigen. Wo es das nicht gibt (busybox), wird die Uhr + # NICHT geprüft — und das steht dann auch so in der Meldung. Eine Prüfung, + # die still durchwinkt, ist schlimmer als keine: sie hält den Nächsten vom + # Nachsehen ab. + _remote_epoch="$(date -u -d "$_remote_date" '+%s' 2>/dev/null || true)" + if [ -z "$_remote_epoch" ]; then + note 'Uhr nicht geprüft (date -d nicht verfügbar)' + return 0 + fi + + _local_epoch="$(date -u '+%s')" + _drift=$((_local_epoch - _remote_epoch)) + [ "$_drift" -lt 0 ] && _drift=$((-_drift)) + + if [ "$_drift" -gt "$CLUPILOT_MAX_CLOCK_DRIFT" ]; then + refuse "Die Uhr geht ${_drift} Sekunden falsch (hier $(date -u '+%Y-%m-%d %H:%M:%SZ'), draußen ${_remote_date}). Damit scheitert jede TLS-Prüfung — Paketquellen, Let's Encrypt, der eigene Rückweg — mit einer Meldung über Zertifikate statt über die Uhrzeit. Stellen mit: date -u -s '${_remote_date}'" + return 0 + fi + + note "Uhr auf ${_drift} s genau" +} + +section_rescue_checked() { + CLUPILOT_REFUSALS='' + CLUPILOT_NOTES='' + + check_is_rescue_system + check_nested_virtualisation + check_disk_size + check_network_and_clock + + if [ -n "$CLUPILOT_REFUSALS" ]; then + printf '\nDiese Maschine ist für die Übernahme nicht geeignet:\n\n%s\n' "$CLUPILOT_REFUSALS" >&2 + CLUPILOT_SECTION_NOTE="$(printf '%s' "$CLUPILOT_REFUSALS" | tr '\n' ' ' | sed -e 's/ */ /g' -e 's/^ *//')" + return 1 + fi + + CLUPILOT_SECTION_NOTE="$CLUPILOT_NOTES" + return 0 +} + main() { parse_arguments "$@" @@ -126,10 +355,12 @@ main() { # Tunnelbeitritt ist das der Aufruf, der die ganze Vorgeschichte nachreicht. flush_reports - # Die Abschnitte kommen mit den Tasks 2 bis 9 dazu, in der Reihenfolge aus - # Spec §7. Der Rumpf steht schon hier, damit sichtbar ist, was noch fehlt: - # - # run_section rescue_checked section_rescue_checked # Task 2 + # Die Abschnitte in der Reihenfolge aus Spec §7. Was noch fehlt, steht als + # Kommentar da, damit die Lücke sichtbar bleibt statt nur nicht zu wirken. + if ! run_section rescue_checked section_rescue_checked; then + 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 # run_section proxmox_installed section_proxmox_installed # Task 4 @@ -139,7 +370,7 @@ main() { # run_section template_built section_template_built # Task 8 # run_section registered section_registered # Task 9 - log 'Gerüst steht. Es sind noch keine Abschnitte eingehängt (Tasks 2 bis 9).' + log 'Eingangsprüfung bestanden. Die Abschnitte ab Task 3 sind noch nicht eingehängt.' } main "$@" 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 c66cc63..78a532d 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 @@ -98,7 +98,7 @@ scheitern statt das Skript abzubrechen. ### Task 2: `rescue_checked` — vor allem anderen nachsehen -- [ ] **Step 1: Schreiben** +- [x] **Step 1: Schreiben** Prüft, bevor irgendetwas geschrieben wird: @@ -113,7 +113,7 @@ genau hier seine einzige Gelegenheit, es nicht zu tun. - [ ] **Step 2: Auf echter Hardware prüfen**, auch der Abbruchfall: einmal auf einer Maschine ohne `/dev/kvm` starten und sehen, dass es sich weigert. -- [ ] **Step 3: Committen.** `Refuse a machine that cannot do the job` +- [x] **Step 3: Committen.** `Refuse a machine that cannot do the job` ---