Refuse a machine that cannot do the job
Four checks, all of them before a single byte is written, because this is the
only moment the script still has the option of not overwriting a disk.
The expensive one is "is this actually a rescue system". It demands POSITIVE
evidence rather than the absence of a counter-argument: a rescue system runs
from RAM, so its root is tmpfs, an overlay or a ramdisk, and an installed
system has a real partition there. A running pveversion is refused outright —
that is a hypervisor with customers on it. Mounted partitions of real disks are
refused too, because at that point the script cannot tell an empty machine from
someone's data.
There is deliberately no flag to skip the check. Such a flag gets used exactly
once, on the evening it should not have been. A rescue system this does not
recognise belongs in the runbook, not in a bypass.
The disk floor is written as arithmetic rather than a number, because the repo
has no threshold to borrow — plans live in the database, not in
config/provisioning.php. Template ~20 GB, Proxmox and its swap and backups
~20 GB, one smallest customer ~50 GB, rounded up to 100. It is explicitly not
capacity planning; HostCapacity and reserve_pct do that after registration.
This only turns away the machine the whole thing cannot fit on.
Network and clock come from one plain-HTTP HEAD, deliberately without TLS —
otherwise a wrong clock would be checking itself and would report a certificate
error instead of the time. Where `date -d` is missing (busybox), the clock is
NOT checked and the report says so: a check that silently waves things through
is worse than none, because it stops the next person from looking. That is the
same lesson R19 records about ->timezone(config('app.timezone')).
Verified here, in both directions where a direction existed. On this VM (root
on /dev/sda1, 80 GB, /dev/kvm present): refuses with three findings at once,
exits 1, writes nothing. In a debian:13-slim container (root on overlay, no
/dev/kvm): the overlay root is accepted as rescue-like and the missing
/dev/kvm is caught, which is the abort case the plan names. Against a receiver
serving a Date header two years off: 63074141 seconds of drift, refused, with
the exact `date -u -s` line to fix it. Step 2 stays unticked — none of these is
a rescue system on a dedicated server.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/host-bootstrap
parent
af284d7d26
commit
25bcab345d
|
|
@ -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 "$@"
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue