377 lines
16 KiB
Bash
Executable File
377 lines
16 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=''
|
|
|
|
# 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
|
|
|
|
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
|
|
|
|
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 ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) usage >&2; die "unbekannte Angabe: $1" ;;
|
|
esac
|
|
done
|
|
|
|
# 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'
|
|
|
|
# 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 `lib/` neben dem Skript.
|
|
#
|
|
# 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")"
|
|
|
|
if [ -f "${_self_dir}/lib/report.sh" ]; then
|
|
printf '%s' "${_self_dir}/lib"
|
|
return 0
|
|
fi
|
|
|
|
if [ -f '/opt/clupilot/bootstrap/lib/report.sh' ]; then
|
|
printf '%s' '/opt/clupilot/bootstrap/lib'
|
|
return 0
|
|
fi
|
|
|
|
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 "$@"
|
|
|
|
_lib="$(resolve_lib_dir)"
|
|
# shellcheck source=lib/report.sh
|
|
. "${_lib}/report.sh"
|
|
|
|
init_reporting
|
|
log "Bootstrap beginnt — Tunnel-Adresse ${CLUPILOT_WG_IP}, CluPilot unter ${CLUPILOT_API}"
|
|
|
|
# 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
|
|
|
|
# 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
|
|
# run_section network_bridged section_network_bridged # Task 5
|
|
# run_section wireguard_joined section_wireguard_joined # Task 6
|
|
# run_section traefik_running section_traefik_running # Task 7
|
|
# 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.'
|
|
}
|
|
|
|
main "$@"
|