1022 lines
43 KiB
Bash
Executable File
1022 lines
43 KiB
Bash
Executable File
#!/bin/sh
|
|
set -eu
|
|
|
|
# Läuft im Rettungssystem des Anbieters, mit genau den Werten, die der
|
|
# Adminbereich in die kopierte Zeile geschrieben hat. Es holt nichts ab: alles,
|
|
# was vor dem Tunnel gebraucht wird, steht bereits hier (Spec §5).
|
|
#
|
|
# Der Ablauf sind Abschnitte. Jeder meldet sich zurück, sobald ein Weg zu
|
|
# CluPilot existiert; die davor werden lokal mitgeschrieben und nachgereicht.
|
|
# Die Abschnittsschlüssel sind die einzige Absprache mit dem Plattform-Plan und
|
|
# stehen unten in `main` in genau der Reihenfolge aus Spec §7.
|
|
#
|
|
# Wiederanlauf: die Fortschrittsdatei ist der Zustand. Ein Abschnitt, der schon
|
|
# `done` gemeldet hat, läuft nicht erneut — so nimmt der systemd-Dienst nach dem
|
|
# Neustart (Task 3) denselben Lauf wieder auf, statt bei Null anzufangen.
|
|
|
|
CLUPILOT_CODE=''
|
|
CLUPILOT_WG_PRIVATE=''
|
|
CLUPILOT_WG_IP=''
|
|
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
|
|
|
|
# Kommt aus `--fqdn`. CluPilot vergibt den Namen; diese Maschine rät ihn nicht.
|
|
CLUPILOT_FQDN=''
|
|
|
|
# Wird von `run_section` gefüllt, wenn ein Abschnitt der Meldung etwas
|
|
# 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
|
|
|
|
# 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}"
|
|
|
|
# Kontaktadresse für Let's Encrypt. Sie taucht in keinem Zertifikat auf, aber
|
|
# an sie gehen die Ablaufwarnungen — eine Adresse, die niemand liest, ist die
|
|
# Vorstufe zu abgelaufenen Kundenzertifikaten.
|
|
#
|
|
# Auf der PLATTFORM-Domain, nicht auf der Kundenzone: `.cloud` gehört den
|
|
# Instanzen der Kunden, der Betreiber sitzt auf `.com`. Hier stand zuerst
|
|
# `ssl@clupilot.cloud` — dieselbe Verwechslung, die auch RegisterHostDns hatte.
|
|
CLUPILOT_ACME_EMAIL="${CLUPILOT_ACME_EMAIL:-ssl@clupilot.com}"
|
|
|
|
usage() {
|
|
cat <<'TEXT'
|
|
clupilot-bootstrap.sh — macht aus einer Maschine im Rettungssystem einen
|
|
fertigen Proxmox-Host.
|
|
|
|
--code <einmal-code> Ausweis für den Rückweg, 32 Zeichen
|
|
--wg-private <schlüssel> WireGuard-Privatschlüssel, von CluPilot erzeugt
|
|
--wg-ip <adresse/präfix> zugeteilte Tunnel-Adresse, z. B. 10.66.0.11/24
|
|
--hub-pubkey <schlüssel> öffentlicher Schlüssel des Hubs
|
|
--hub-endpoint <host:port> Adresse des Hubs
|
|
--api <url> CluPilot über die TUNNEL-Adresse, ohne Schrägstrich
|
|
am Ende, z. B. http://10.66.0.1
|
|
--fqdn <name> der Name, den CluPilot für diesen Host führt,
|
|
z. B. fsn-01.node.clupilot.com
|
|
|
|
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
|
|
}
|
|
|
|
# Nach stderr, damit stdout frei bleibt, und zusätzlich in eine Datei — der
|
|
# Betreiber sieht die Konsole, aber wer hinterher nachsieht, hat nur die Datei.
|
|
log() {
|
|
_line="$(date -u '+%Y-%m-%dT%H:%M:%SZ') $*"
|
|
printf '%s\n' "$_line" >&2
|
|
[ -d "${CLUPILOT_STATE_DIR:-}" ] && printf '%s\n' "$_line" >> "${CLUPILOT_STATE_DIR}/bootstrap.log"
|
|
return 0
|
|
}
|
|
|
|
die() {
|
|
printf 'clupilot-bootstrap: %s\n' "$*" >&2
|
|
exit 1
|
|
}
|
|
|
|
parse_arguments() {
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--code) CLUPILOT_CODE="${2:-}"; shift 2 ;;
|
|
--wg-private) CLUPILOT_WG_PRIVATE="${2:-}"; shift 2 ;;
|
|
--wg-ip) CLUPILOT_WG_IP="${2:-}"; shift 2 ;;
|
|
--hub-pubkey) CLUPILOT_HUB_PUBKEY="${2:-}"; shift 2 ;;
|
|
--hub-endpoint) CLUPILOT_HUB_ENDPOINT="${2:-}"; shift 2 ;;
|
|
--api) CLUPILOT_API="${2:-}"; shift 2 ;;
|
|
--fqdn) CLUPILOT_FQDN="${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.
|
|
[ -n "$CLUPILOT_CODE" ] || die '--code fehlt'
|
|
[ -n "$CLUPILOT_WG_PRIVATE" ] || die '--wg-private fehlt'
|
|
[ -n "$CLUPILOT_WG_IP" ] || die '--wg-ip fehlt'
|
|
[ -n "$CLUPILOT_HUB_PUBKEY" ] || die '--hub-pubkey fehlt'
|
|
[ -n "$CLUPILOT_HUB_ENDPOINT" ] || die '--hub-endpoint fehlt'
|
|
[ -n "$CLUPILOT_API" ] || die '--api fehlt'
|
|
[ -n "$CLUPILOT_FQDN" ] || die '--fqdn fehlt'
|
|
|
|
# Ein Schrägstrich am Ende ergäbe `…//host/progress`. Manche Router
|
|
# beantworten das, manche nicht — und der Unterschied fiele erst beim ersten
|
|
# Melden auf, also nach dem Tunnelbeitritt.
|
|
CLUPILOT_API="${CLUPILOT_API%/}"
|
|
}
|
|
|
|
# Findet das Verzeichnis, in dem dieses Skript samt `lib/` liegt.
|
|
#
|
|
# 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 [ -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'
|
|
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. Er kommt aus der Befehlszeile.
|
|
#
|
|
# CluPilot kennt den Namen dieses Hosts — es hat ihn gerade vergeben —, also
|
|
# gibt CluPilot ihn mit. Die erste Fassung leitete ihn hier ab: erst aus der
|
|
# Rückwärtsauflösung, sonst aus der Tunneladresse, notfalls gegen eine fest
|
|
# verdrahtete Domain. Alle drei waren falsch.
|
|
#
|
|
# Ein geratener Name bleibt nämlich stehen. Er landet in `/etc/hostname`, in
|
|
# `/etc/hosts`, in jeder Protokollzeile dieser Maschine und in jedem
|
|
# Zertifikatsantrag, den sie später stellt. Und die fest verdrahtete Domain war
|
|
# `clupilot.net` — eine, die in diesem Projekt nirgends sonst vorkommt: die
|
|
# Plattform liegt auf `.com`, die Kundeninstanzen auf `.cloud`, und `.net` war
|
|
# schlicht erfunden. `PrepareBaseSystem.php` macht denselben Fehler.
|
|
#
|
|
# Der Rückfall bleibt, aber er rät nicht mehr über die Domain: ohne `--fqdn`
|
|
# wird abgebrochen.
|
|
require_fqdn() {
|
|
if [ -n "$CLUPILOT_FQDN" ]; then
|
|
return 0
|
|
fi
|
|
|
|
die '--fqdn fehlt. Der Name dieses Hosts wird von CluPilot vergeben und nicht auf der Maschine erraten — ein geratener Name bleibt in /etc/hosts und in jedem späteren Zertifikatsantrag stehen.'
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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() {
|
|
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.
|
|
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
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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)"
|
|
|
|
require_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.'
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Abschnitt 4: proxmox_installed — abnehmen, nicht installieren
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# PVE kam mit dem Abbild. Dieser Abschnitt stellt fest, dass es wirklich läuft,
|
|
# dass Abbild und Unterbau zusammenpassen, und bringt die Paketquellen in
|
|
# Ordnung — ohne die kommt kein einziger der folgenden Abschnitte an seine
|
|
# Pakete.
|
|
section_proxmox_installed() {
|
|
if ! command -v pveversion >/dev/null 2>&1; then
|
|
CLUPILOT_SECTION_NOTE='pveversion gibt es nicht — auf dieser Maschine läuft kein Proxmox'
|
|
return 1
|
|
fi
|
|
|
|
# Der Kernel ist die Aussage, nicht das Paket. Ein installiertes
|
|
# proxmox-ve bei laufendem Debian-Kernel ist genau der halbe Zustand, aus
|
|
# dem RebootIntoPveKernel entstanden ist.
|
|
_kernel="$(uname -r)"
|
|
case "$_kernel" in
|
|
*-pve*) ;;
|
|
*)
|
|
CLUPILOT_SECTION_NOTE="läuft auf ${_kernel}, das ist kein PVE-Kernel"
|
|
return 1
|
|
;;
|
|
esac
|
|
|
|
_id="$(os_release_field id)"
|
|
_codename="$(os_release_field codename)"
|
|
if [ "$_id" != 'debian' ] || ! _release="$(proxmox_release_for "$_codename")"; then
|
|
CLUPILOT_SECTION_NOTE="unbekannter Unterbau: ID=${_id:-(leer)}, VERSION_CODENAME=${_codename:-(leer)} ($(os_release_field pretty)). Dieses Skript kennt keine dazu passende Proxmox-Suite und rät nicht."
|
|
return 1
|
|
fi
|
|
|
|
# Abbild und Unterbau müssen zusammengehören. Passen sie nicht, wurde das
|
|
# falsche Abbild installiert — und das heilt keine Paketquelle, das ist ein
|
|
# PVE gegen eine andere libc.
|
|
_pve_version="$(pveversion 2>/dev/null | head -1)"
|
|
_pve_major="$(printf '%s' "$_pve_version" | sed -n 's#^pve-manager/\([0-9]*\)\..*#\1#p')"
|
|
_expected_major="$(pve_major_for_codename "$_codename")"
|
|
|
|
_pairing='ungeprüft'
|
|
if [ -z "$_pve_major" ]; then
|
|
# Nicht stillschweigend durchwinken. Eine Prüfung, die nicht gelaufen
|
|
# ist, muss das sagen — sonst hält sie den Nächsten vom Nachsehen ab,
|
|
# und genau das hält R19 als eigenen Fund fest.
|
|
_pairing="Paarung UNGEPRÜFT, pveversion nicht lesbar: ${_pve_version:-(keine Ausgabe)}"
|
|
log "$_pairing"
|
|
elif [ "$_pve_major" != "$_expected_major" ]; then
|
|
CLUPILOT_SECTION_NOTE="${_pve_version} auf Debian ${_codename}: erwartet wäre PVE ${_expected_major}. Das Abbild passt nicht zum Unterbau; die Maschine wird mit dem richtigen Abbild neu aufgesetzt."
|
|
return 1
|
|
else
|
|
_pairing="PVE ${_pve_major} zu Debian ${_codename}"
|
|
fi
|
|
|
|
split_proxmox_release "$_release"
|
|
|
|
if ! _keyring_path="$(install_proxmox_key "$CLUPILOT_PVE_KEY_URL" "$CLUPILOT_PVE_KEYRING")"; then
|
|
CLUPILOT_SECTION_NOTE="Proxmox-Release-Schlüssel nicht ladbar: ${CLUPILOT_PVE_KEY_URL}"
|
|
return 1
|
|
fi
|
|
|
|
remove_subscription_sources
|
|
write_proxmox_sources "$CLUPILOT_PVE_SUITE" "$_keyring_path" '/etc/apt/sources.list.d/pve-install-repo.sources'
|
|
|
|
# Der eigentliche Beweis. Solange irgendeine Abonnement-Quelle stehen bleibt,
|
|
# endet das hier mit Fehler — und dann ist sie überschrieben worden statt
|
|
# entfernt.
|
|
export DEBIAN_FRONTEND=noninteractive
|
|
if ! apt-get update >> "${CLUPILOT_WORK_DIR}/apt.log" 2>&1; then
|
|
CLUPILOT_SECTION_NOTE="apt-get update schlägt fehl; siehe ${CLUPILOT_WORK_DIR}/apt.log"
|
|
return 1
|
|
fi
|
|
|
|
CLUPILOT_SECTION_NOTE="${_pve_version} auf ${_kernel}; ${_pairing}; Quellen auf ${CLUPILOT_PVE_SUITE}/pve-no-subscription"
|
|
return 0
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Abschnitt 5: network_bridged — der gefährlichste Abschnitt
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# Gefährlich ist er nur noch in seinem Ausnahmefall. Über die ISO legt der
|
|
# Installer `vmbr0` selbst an; dieser Abschnitt nimmt sie dann ab, statt sie zu
|
|
# bauen. Muss doch gebaut werden, greift die Selbstrücknahme.
|
|
section_network_bridged() {
|
|
_iface="$(detect_primary_interface)"
|
|
_style="$(detect_network_style "$_iface")"
|
|
|
|
if bridge_exists vmbr0 && bridge_has_address vmbr0 && bridge_carries_default_route vmbr0; then
|
|
log "vmbr0 steht und trägt die Vorgaberoute (Anbieterform: ${_style})"
|
|
_built='vom Installer'
|
|
else
|
|
# Ab hier wird an dem Netz gearbeitet, über das dieser Lauf gerade läuft.
|
|
if [ -z "$_iface" ]; then
|
|
CLUPILOT_SECTION_NOTE='keine primäre Netzkarte gefunden — ohne die lässt sich keine Brücke bauen'
|
|
return 1
|
|
fi
|
|
|
|
_cidr="$(ip -4 -o addr show dev "$_iface" scope global 2>/dev/null | awk '{ print $4; exit }')"
|
|
_gw="$(ip -4 route show default 2>/dev/null | awk '{ print $3; exit }')"
|
|
|
|
if [ -z "$_cidr" ] || [ -z "$_gw" ]; then
|
|
CLUPILOT_SECTION_NOTE="keine Adresse oder kein Gateway auf ${_iface} — zu wenig, um eine Brücke zu bauen, die danach noch trägt"
|
|
return 1
|
|
fi
|
|
|
|
log "keine vmbr0 vorhanden; baue sie über ${_iface} (${_style}, ${_cidr} via ${_gw})"
|
|
|
|
if ! backup_network_config; then
|
|
CLUPILOT_SECTION_NOTE='Netzkonfiguration ließ sich nicht sichern — ohne Sicherung wird hier nichts umgestellt'
|
|
return 1
|
|
fi
|
|
|
|
# Der Zeitgeber steht VOR der Umstellung. Danach ihn zu stellen hieße,
|
|
# ihn genau dann stellen zu wollen, wenn die Verbindung schon weg ist.
|
|
schedule_network_rollback 5
|
|
build_bridge "$_iface" "$_style" "$_cidr" "$_gw"
|
|
|
|
# Dem Netz einen Moment geben, bevor gefragt wird.
|
|
sleep 10
|
|
|
|
if ! host_still_reachable; then
|
|
# Nichts zurückspielen und nicht abbestellen: der Zeitgeber macht
|
|
# das gleich selbst, und er macht es auch dann, wenn dieser Lauf
|
|
# hier stirbt.
|
|
CLUPILOT_SECTION_NOTE='die Brücke hat den Host vom Netz genommen; die Selbstrücknahme läuft in wenigen Minuten und stellt den vorigen Stand wieder her'
|
|
return 1
|
|
fi
|
|
|
|
cancel_network_rollback
|
|
_built="von diesem Skript (${_style})"
|
|
fi
|
|
|
|
if ! enable_datacenter_firewall; then
|
|
CLUPILOT_SECTION_NOTE='die Datacenter-Firewall ließ sich nicht einschalten — ohne sie wären die 80/443-Regeln jeder Kunden-VM wirkungslos'
|
|
return 1
|
|
fi
|
|
|
|
CLUPILOT_SECTION_NOTE="vmbr0 ${_built}, Datacenter-Firewall an"
|
|
return 0
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Abschnitt 6: wireguard_joined — ab hier ist CluPilot erreichbar
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# Der erste Abschnitt, nach dem es einen Rückweg gibt. Deshalb endet er mit
|
|
# `flush_reports`: alles, was seit dem Rettungssystem liegengeblieben ist, geht
|
|
# jetzt raus — mit den Zeitstempeln von damals.
|
|
section_wireguard_joined() {
|
|
export DEBIAN_FRONTEND=noninteractive
|
|
if ! apt-get install -y wireguard >> "${CLUPILOT_WORK_DIR}/apt.log" 2>&1; then
|
|
CLUPILOT_SECTION_NOTE='wireguard ließ sich nicht installieren'
|
|
return 1
|
|
fi
|
|
|
|
mkdir -p /etc/wireguard
|
|
chmod 700 /etc/wireguard
|
|
|
|
_desired="$(render_wireguard_config)"
|
|
_current="$(cat /etc/wireguard/wg0.conf 2>/dev/null || true)"
|
|
|
|
# Vor dem Schreiben vergleichen. Die Datei wurde früher bedingungslos neu
|
|
# geschrieben, ohne dass irgendetwas sie neu einlas — wer einen falschen
|
|
# Hub-Schlüssel korrigierte, sah die neue Datei und kein geändertes
|
|
# Verhalten, weil die laufende Schnittstelle den alten Peer behielt.
|
|
_changed=0
|
|
if [ "$_desired" != "$_current" ]; then
|
|
_changed=1
|
|
umask 077
|
|
printf '%s\n' "$_desired" > /etc/wireguard/wg0.conf
|
|
chmod 600 /etc/wireguard/wg0.conf
|
|
fi
|
|
|
|
if ! bring_up_wireguard "$_changed"; then
|
|
CLUPILOT_SECTION_NOTE='wg0 kam nicht in den Zustand, den die Übernahme braucht'
|
|
return 1
|
|
fi
|
|
|
|
if ! wireguard_handshake_proven 10; then
|
|
CLUPILOT_SECTION_NOTE="kein Handshake mit $(api_host) über den Tunnel. Die Datei steht, der Tunnel nicht — und eine geschriebene Datei ist kein Tunnel."
|
|
return 1
|
|
fi
|
|
|
|
CLUPILOT_SECTION_NOTE="Handshake mit $(api_host) bewiesen, wg0 systemd-freigegeben"
|
|
return 0
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Abschnitt 7: traefik_running
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# Dienst, Ports, Ping. Der Beweis, dass eine Routentabelle wirklich geholt wurde,
|
|
# steht in Abschnitt 9 — er braucht den Token, den es erst mit der Registrierung
|
|
# gibt. Das ist kein Weglassen: er wird geführt, nur an der Stelle, an der er
|
|
# geführt werden kann.
|
|
section_traefik_running() {
|
|
_version="$(discover_traefik_version)"
|
|
if [ -z "$_version" ]; then
|
|
CLUPILOT_SECTION_NOTE='keine Traefik-v3-Ausgabe gefunden'
|
|
return 1
|
|
fi
|
|
|
|
if ! install_traefik_binary "$_version"; then
|
|
CLUPILOT_SECTION_NOTE="Traefik ${_version} nicht installierbar oder Prüfsumme falsch"
|
|
return 1
|
|
fi
|
|
|
|
# Noch ohne Token: der kommt in Abschnitt 9. Der Dienst soll trotzdem jetzt
|
|
# schon stehen und die Ports halten, damit die Reihenfolge der Abschnitte in
|
|
# der Konsole das abbildet, was auf der Maschine passiert.
|
|
write_traefik_static_config ''
|
|
write_traefik_unit
|
|
|
|
if ! systemctl enable --now traefik >/dev/null 2>&1; then
|
|
CLUPILOT_SECTION_NOTE='traefik.service ließ sich nicht starten'
|
|
return 1
|
|
fi
|
|
|
|
if ! traefik_answers_ping 15; then
|
|
CLUPILOT_SECTION_NOTE='Traefik antwortet nicht am Ping-Endpunkt; siehe /var/log/traefik.log'
|
|
return 1
|
|
fi
|
|
|
|
if ! traefik_ports_bound; then
|
|
CLUPILOT_SECTION_NOTE='Traefik läuft, aber 80 und 443 sind nicht belegt — ein Entrypoint fehlt'
|
|
return 1
|
|
fi
|
|
|
|
CLUPILOT_SECTION_NOTE="Traefik ${_version} läuft, 80 und 443 belegt, Routentabelle folgt mit der Registrierung"
|
|
return 0
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Abschnitt 8: template_built — die goldene Vorlage
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# Jede der drei Fallen aus Block A wird hier GEPRÜFT, nicht angenommen. Der
|
|
# Handoff nennt den Preis: der erste bezahlte Auftrag starb *nach* der Zahlung,
|
|
# weil die Vorlage nur so aussah, als wäre sie fertig.
|
|
section_template_built() {
|
|
export DEBIAN_FRONTEND=noninteractive
|
|
if ! apt-get install -y libguestfs-tools >> "${CLUPILOT_WORK_DIR}/apt.log" 2>&1; then
|
|
CLUPILOT_SECTION_NOTE='libguestfs-tools ließ sich nicht installieren — ohne virt-customize entsteht keine Vorlage'
|
|
return 1
|
|
fi
|
|
|
|
_storage="$(detect_vm_storage)"
|
|
if [ -z "$_storage" ]; then
|
|
CLUPILOT_SECTION_NOTE='kein Proxmox-Speicher gefunden, der Platten aufnimmt'
|
|
return 1
|
|
fi
|
|
|
|
_image="${CLUPILOT_WORK_DIR}/${CLUPILOT_CLOUD_IMAGE}"
|
|
if [ ! -f "$_image" ] && ! fetch_cloud_image "$_image"; then
|
|
CLUPILOT_SECTION_NOTE='Debian-Cloud-Abbild nicht ladbar oder Prüfsumme falsch'
|
|
return 1
|
|
fi
|
|
|
|
# Falle 2, VOR dem Umbauen: taugt der Aufbau überhaupt?
|
|
if ! verify_image_layout "$_image"; then
|
|
CLUPILOT_SECTION_NOTE='Der Aufbau des Cloud-Abbilds erfüllt Falle 2 nicht (LVM oder Wurzel nicht als letzte Partition)'
|
|
return 1
|
|
fi
|
|
|
|
_compose="${CLUPILOT_BOOTSTRAP_DIR}/assets/docker-compose.yml"
|
|
if [ ! -f "$_compose" ]; then
|
|
CLUPILOT_SECTION_NOTE="assets/docker-compose.yml fehlt unter ${CLUPILOT_BOOTSTRAP_DIR}"
|
|
return 1
|
|
fi
|
|
|
|
if ! customise_cloud_image "$_image" "$_compose"; then
|
|
CLUPILOT_SECTION_NOTE="Abbild ließ sich nicht umbauen; siehe ${CLUPILOT_WORK_DIR}/virt-customize.log"
|
|
return 1
|
|
fi
|
|
|
|
# Fallen 1 und 3, NACH dem Umbauen: virt-customize meldet auch dann Erfolg,
|
|
# wenn apt ein Paket stillschweigend nicht installiert hat.
|
|
if ! verify_image_contents "$_image"; then
|
|
CLUPILOT_SECTION_NOTE='Das fertige Abbild erfüllt Falle 1 oder Falle 3 nicht'
|
|
return 1
|
|
fi
|
|
|
|
if ! create_proxmox_template "$_image" "$_storage"; then
|
|
CLUPILOT_SECTION_NOTE="Vorlage ließ sich nicht anlegen; siehe ${CLUPILOT_WORK_DIR}/qm.log"
|
|
return 1
|
|
fi
|
|
|
|
# Das Merkmal, nicht die Existenz. Eine VM, die zufällig 9000 heißt, ist
|
|
# keine Vorlage — und `VerifyVmTemplate` prüft heute genau das Falsche.
|
|
if ! template_is_really_a_template; then
|
|
CLUPILOT_SECTION_NOTE="VMID ${CLUPILOT_TEMPLATE_VMID} existiert, meldet aber nicht template: 1"
|
|
return 1
|
|
fi
|
|
|
|
CLUPILOT_SECTION_NOTE="Vorlage ${CLUPILOT_TEMPLATE_VMID} auf ${_storage}, alle drei Fallen geprüft"
|
|
return 0
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Abschnitt 9: registered — Token übergeben, und erst dann zusperren
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# Der letzte Abschnitt trägt drei Dinge, die alle hierher gehören und nirgendwo
|
|
# sonst hin konnten:
|
|
#
|
|
# - die Registrierung samt Schlüsseltausch,
|
|
# - Traefiks Token und der Beweis, dass eine Routentabelle wirklich kommt
|
|
# (Abschnitt 7 konnte ihn nicht führen, es gab den Token noch nicht),
|
|
# - die nftables-Sperre, die im alten Ablauf aus gutem Grund ZULETZT lief.
|
|
section_registered() {
|
|
export DEBIAN_FRONTEND=noninteractive
|
|
apt-get install -y jq >> "${CLUPILOT_WORK_DIR}/apt.log" 2>&1 || true
|
|
|
|
if ! converge_proxmox_role; then
|
|
CLUPILOT_SECTION_NOTE='die Proxmox-Rolle ließ sich nicht angleichen — ohne Sys.Modify stirbt jede Kundenbereitstellung am Backup-Schritt'
|
|
return 1
|
|
fi
|
|
|
|
if ! ensure_proxmox_user; then
|
|
CLUPILOT_SECTION_NOTE='Proxmox-Benutzer oder Rollenzuweisung ließen sich nicht herstellen'
|
|
return 1
|
|
fi
|
|
|
|
_api_token="$(mint_proxmox_token)"
|
|
if [ -z "$_api_token" ]; then
|
|
CLUPILOT_SECTION_NOTE='Proxmox-API-Token ließ sich nicht erzeugen'
|
|
return 1
|
|
fi
|
|
|
|
# Der frische Schlüssel. Der aus der Befehlszeile stand in einer
|
|
# Zwischenablage und wird damit wertlos (Spec §5).
|
|
umask 077
|
|
_new_private="$(wg genkey)"
|
|
_new_public="$(printf '%s' "$_new_private" | wg pubkey)"
|
|
if [ -z "$_new_public" ]; then
|
|
CLUPILOT_SECTION_NOTE='neues WireGuard-Schlüsselpaar ließ sich nicht erzeugen'
|
|
return 1
|
|
fi
|
|
|
|
_response="$(post_registration "$(render_register_payload "$_api_token" "$_new_public")")"
|
|
if [ -z "$_response" ]; then
|
|
CLUPILOT_SECTION_NOTE="POST ${CLUPILOT_API}/host/register hat nicht geantwortet"
|
|
return 1
|
|
fi
|
|
|
|
_host_token="$(json_field host_token "$_response")"
|
|
if [ -z "$_host_token" ]; then
|
|
CLUPILOT_SECTION_NOTE='die Registrierung kam ohne host_token zurück — ohne den holt Traefik nie eine Routentabelle'
|
|
return 1
|
|
fi
|
|
|
|
# Erst registrieren und die Antwort haben, DANN tauschen — und den Tausch
|
|
# beweisen, bevor der alte Schlüssel verworfen ist.
|
|
if ! swap_wireguard_key "$_new_private"; then
|
|
CLUPILOT_SECTION_NOTE='der Schlüsseltausch hat keinen Handshake ergeben; es wurde auf den alten Schlüssel zurückgestellt'
|
|
return 1
|
|
fi
|
|
|
|
# Jetzt kann Traefik seine Tabelle holen.
|
|
write_traefik_static_config "$_host_token"
|
|
systemctl restart traefik >/dev/null 2>&1 || true
|
|
|
|
if ! traefik_answers_ping 15; then
|
|
CLUPILOT_SECTION_NOTE='Traefik antwortet nach dem Einsetzen des Tokens nicht mehr'
|
|
return 1
|
|
fi
|
|
|
|
if ! host_routes_reachable "$_host_token"; then
|
|
CLUPILOT_SECTION_NOTE="die Routentabelle unter ${CLUPILOT_API}/host/routes ist mit diesem Token nicht abrufbar — ein Host, der Traefik hat, aber nie eine Tabelle bekam, ist nicht fertig"
|
|
return 1
|
|
fi
|
|
|
|
# Ganz zuletzt zusperren. Ab hier ist Port 22 nur noch über den Tunnel da.
|
|
if ! apply_host_firewall; then
|
|
CLUPILOT_SECTION_NOTE='die Host-Firewall ließ sich nicht anwenden; der Host bleibt offen, statt halb gesperrt zu sein'
|
|
return 1
|
|
fi
|
|
|
|
CLUPILOT_SECTION_NOTE='Token übergeben, Schlüssel getauscht und Handshake erneut bewiesen, Routentabelle abgerufen, Host abgesperrt'
|
|
return 0
|
|
}
|
|
|
|
main() {
|
|
parse_arguments "$@"
|
|
|
|
CLUPILOT_BOOTSTRAP_DIR="$(resolve_bootstrap_dir)"
|
|
# shellcheck source=lib/report.sh
|
|
. "${CLUPILOT_BOOTSTRAP_DIR}/lib/report.sh"
|
|
# shellcheck source=lib/proxmox.sh
|
|
. "${CLUPILOT_BOOTSTRAP_DIR}/lib/proxmox.sh"
|
|
# shellcheck source=lib/network.sh
|
|
. "${CLUPILOT_BOOTSTRAP_DIR}/lib/network.sh"
|
|
# shellcheck source=lib/traefik.sh
|
|
. "${CLUPILOT_BOOTSTRAP_DIR}/lib/traefik.sh"
|
|
# shellcheck source=lib/template.sh
|
|
. "${CLUPILOT_BOOTSTRAP_DIR}/lib/template.sh"
|
|
# shellcheck source=lib/register.sh
|
|
. "${CLUPILOT_BOOTSTRAP_DIR}/lib/register.sh"
|
|
|
|
init_reporting
|
|
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.
|
|
flush_reports
|
|
|
|
# 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
|
|
|
|
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
|
|
|
|
if ! run_section proxmox_installed section_proxmox_installed; then
|
|
die 'Abbruch: Proxmox ist nicht in dem Zustand, den die folgenden Abschnitte voraussetzen.'
|
|
fi
|
|
|
|
if ! run_section network_bridged section_network_bridged; then
|
|
die 'Abbruch bei der Netzumstellung.'
|
|
fi
|
|
|
|
if ! run_section wireguard_joined section_wireguard_joined; then
|
|
die 'Abbruch: ohne Tunnel gibt es keinen Rückweg zu CluPilot.'
|
|
fi
|
|
|
|
# Ab hier existiert ein Weg. Die ganze Vorgeschichte geht jetzt raus.
|
|
flush_reports
|
|
|
|
if ! run_section traefik_running section_traefik_running; then
|
|
die 'Abbruch: ohne Traefik erreicht kein Kunde je seine Instanz.'
|
|
fi
|
|
|
|
if ! run_section template_built section_template_built; then
|
|
die 'Abbruch: ohne bewiesene Vorlage stirbt der erste bezahlte Auftrag nach der Zahlung.'
|
|
fi
|
|
|
|
if ! run_section registered section_registered; then
|
|
die 'Abbruch bei der Übergabe. Der Host ist NICHT abgesperrt und NICHT fertig.'
|
|
fi
|
|
|
|
flush_reports
|
|
log 'Fertig. Der Host hat sich gemeldet; die Konsole übernimmt ab hier.'
|
|
}
|
|
|
|
main "$@"
|