diff --git a/deploy/bootstrap/clupilot-bootstrap.sh b/deploy/bootstrap/clupilot-bootstrap.sh index 7870a38..2f4f5ee 100755 --- a/deploy/bootstrap/clupilot-bootstrap.sh +++ b/deploy/bootstrap/clupilot-bootstrap.sh @@ -851,6 +851,90 @@ section_template_built() { 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 "$@" @@ -865,6 +949,8 @@ main() { . "${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 @@ -915,9 +1001,12 @@ main() { die 'Abbruch: ohne bewiesene Vorlage stirbt der erste bezahlte Auftrag nach der Zahlung.' fi - # run_section registered section_registered # Task 9 + if ! run_section registered section_registered; then + die 'Abbruch bei der Übergabe. Der Host ist NICHT abgesperrt und NICHT fertig.' + fi - log 'Proxmox abgenommen, Quellen in Ordnung. Die Abschnitte ab Task 5 sind noch nicht eingehängt.' + flush_reports + log 'Fertig. Der Host hat sich gemeldet; die Konsole übernimmt ab hier.' } main "$@" diff --git a/deploy/bootstrap/lib/network.sh b/deploy/bootstrap/lib/network.sh index f7de3d2..aeead2b 100644 --- a/deploy/bootstrap/lib/network.sh +++ b/deploy/bootstrap/lib/network.sh @@ -372,6 +372,134 @@ wireguard_handshake_proven() { return 1 } +# --------------------------------------------------------------------------- +# Die Host-Firewall (nftables) +# --------------------------------------------------------------------------- +# +# Läuft ZULETZT, in Abschnitt `registered`. Der Grund steht in +# `SecureHostFirewall.php` und ist eine Reihenfolgefrage: erst wenn der Tunnel +# jeden vorigen Schritt getragen hat, darf SSH nach außen zugehen. Früher +# geschlossen, und ein Host hinge mitten in der Übernahme fest. +# +# nftables statt pve-firewall, und nur die INPUT-Kette: FORWARD und OUTPUT +# bleiben auf der Kernel-Vorgabe, damit der Verkehr der Kunden-VMs über die +# Brücken nie von einer Host-Regel berührt wird. Zwei Filter, die beide den +# Eingang besitzen wollen, sind der Weg zu einem unerreichbaren Host — deshalb +# schaltet `enable_datacenter_firewall` die Node-Firewall ausdrücklich ab. +# +# **Keine automatische Wiederöffnung.** Kein Zeitgeber, kein „nach N Minuten ohne +# Handshake wieder auf". Eine Firewall, die sich unter Störung selbst öffnet, ist +# keine Firewall. Für den Notfall liegt ein Skript bereit, das von der +# Anbieterkonsole aus gestartet wird. + +CLUPILOT_EMERGENCY_SCRIPT='/usr/local/sbin/clupilot-emergency-open-firewall.sh' + +render_nftables_config() { + _wg_subnet="$1" + + cat </dev/null || true +nft flush ruleset 2>/dev/null || true + +echo "Firewallregeln verworfen: dieser Host nimmt jetzt auf jedem Port an." +SH +} + +# Sperrt den Host ab. Verlangt VORHER einen bewiesenen Tunnel und fasst sonst +# nichts an. +apply_host_firewall() { + if ! wireguard_handshake_proven 3; then + log 'Tunnel nicht bewiesen — die Host-Firewall wird nicht angewendet' + return 1 + fi + + export DEBIAN_FRONTEND=noninteractive + apt-get install -y nftables >/dev/null 2>&1 || { log 'nftables ließ sich nicht installieren'; return 1; } + + render_nftables_config "$(ipv4_network "$CLUPILOT_WG_IP")" > /etc/nftables.conf + render_emergency_script > "$CLUPILOT_EMERGENCY_SCRIPT" + chmod 700 "$CLUPILOT_EMERGENCY_SCRIPT" + + # Erst prüfen, dann anwenden. Ein Regelwerk mit einem Tippfehler wird von + # `nft -f` teilweise geladen — und „teilweise" heißt hier: policy drop steht, + # die Ausnahmen nicht. + if ! nft -c -f /etc/nftables.conf >/dev/null 2>&1; then + log 'nftables-Regelwerk ist nicht gültig — es wird nichts angewendet' + return 1 + fi + + systemctl enable --now nftables >/dev/null 2>&1 || { log 'nftables ließ sich nicht aktivieren'; return 1; } + systemctl reload-or-restart nftables >/dev/null 2>&1 || nft -f /etc/nftables.conf + + return 0 +} + # --------------------------------------------------------------------------- # Die Datacenter-Firewall # --------------------------------------------------------------------------- diff --git a/deploy/bootstrap/lib/register.sh b/deploy/bootstrap/lib/register.sh new file mode 100644 index 0000000..8bbb461 --- /dev/null +++ b/deploy/bootstrap/lib/register.sh @@ -0,0 +1,211 @@ +# shellcheck shell=sh +# +# Die Übergabe: Proxmox-Rolle und Token erzeugen, bei CluPilot registrieren, den +# WireGuard-Schlüssel tauschen. +# +# --------------------------------------------------------------------------- +# Die Rolle, und warum `|| true` hier NICHT steht +# --------------------------------------------------------------------------- +# +# `pveum role add … || true` wendet die Rechteliste nur bei dem Lauf an, der die +# Rolle zum ersten Mal anlegt. Ein Recht, das später dazukam, erreichte damit +# neue Hosts und keinen einzigen bestehenden. Genau so fehlte `Sys.Modify` +# überall — mit einem Symptom, das viel später und ganz woanders auftrat: ein +# 403 auf `POST /cluster/backup`, und die Bereitstellung eines zahlenden Kunden +# scheiterte bei `register_backup`. +# +# Deshalb: `role add`, und wenn das scheitert (die Rolle gibt es schon, der +# gewöhnliche Fall), `role modify` — zwei getrennte Befehle, damit ein +# Fehlschlag zuzuordnen ist. `modify` ohne `-append` ERSETZT die Liste, der Host +# passt sich also der Konfiguration an, statt anzusammeln, was irgendeine +# frühere Fassung einmal gewährt hat. +# +# Für Benutzer und ACL galt `|| true` bisher als geduldet, weil „gibt es schon" +# dort der Normalfall ist. Auch das entfällt: statt den Fehler zu verwerfen, +# wird nachgesehen, ob der gewünschte Zustand steht. Ein `|| true` verdeckt +# nicht nur „gibt es schon", sondern auch „ging schief". + +CLUPILOT_PVE_ROLE='CluPilotAutomation' +CLUPILOT_PVE_USER='automation@pve' +CLUPILOT_PVE_TOKEN_NAME='clupilot' + +# Wörtlich aus `config/provisioning.php`. Sys.Modify ist nicht optional: es steht +# in `PVE/API2/Backup.pm` als `check => ['perm', '/', ['Sys.Modify']]` — an der +# Quelle geprüft, nicht aus einem Forenbeitrag geschlossen — und deckt zugleich +# `PUT /cluster/firewall/options` ab. Gewährt auf `/`, weil beide Endpunkte +# gegen `/` prüfen und nichts Engeres sie zufriedenstellt. +CLUPILOT_PVE_PRIVS='VM.Allocate,VM.Clone,VM.Config.Disk,VM.Config.CPU,VM.Config.Memory,VM.Config.Network,VM.Config.Options,VM.Config.Cloudinit,VM.PowerMgmt,VM.Monitor,VM.Audit,VM.Backup,VM.GuestAgent.Audit,VM.GuestAgent.Unrestricted,Datastore.AllocateSpace,Datastore.Audit,Sys.Audit,Sys.Modify' + +# Ein Feld aus flachem JSON. Nimmt `jq`, wenn es da ist, und sonst einen +# eng gefassten Ausdruck. +json_field() { + _key="$1" + _json="$2" + + if command -v jq >/dev/null 2>&1; then + printf '%s' "$_json" | jq -r --arg k "$_key" '.[$k] // empty' 2>/dev/null + return 0 + fi + + printf '%s' "$_json" \ + | sed -n "s/.*\"${_key}\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" \ + | head -1 +} + +converge_proxmox_role() { + if pveum role add "$CLUPILOT_PVE_ROLE" -privs "$CLUPILOT_PVE_PRIVS" >/dev/null 2>&1; then + log "Proxmox-Rolle ${CLUPILOT_PVE_ROLE} angelegt" + return 0 + fi + + if pveum role modify "$CLUPILOT_PVE_ROLE" -privs "$CLUPILOT_PVE_PRIVS" >/dev/null 2>&1; then + log "Proxmox-Rolle ${CLUPILOT_PVE_ROLE} auf die aktuelle Rechteliste gebracht" + return 0 + fi + + log 'Die Rechteliste der Proxmox-Rolle ließ sich nicht angleichen' + return 1 +} + +# Benutzer und ACL — ohne `|| true`, aber mit Nachsehen. +ensure_proxmox_user() { + if ! pveum user list --output-format json 2>/dev/null | grep -q "\"${CLUPILOT_PVE_USER}\""; then + if ! pveum user add "$CLUPILOT_PVE_USER" >/dev/null 2>&1; then + # Nicht blind schlucken: nachsehen, ob er jetzt da ist. Wenn ja, war + # es ein Wettlauf und kein Fehler. + if ! pveum user list --output-format json 2>/dev/null | grep -q "\"${CLUPILOT_PVE_USER}\""; then + log "Proxmox-Benutzer ${CLUPILOT_PVE_USER} ließ sich nicht anlegen" + return 1 + fi + fi + fi + + if ! pveum acl modify / -user "$CLUPILOT_PVE_USER" -role "$CLUPILOT_PVE_ROLE" >/dev/null 2>&1; then + # Auch hier: gilt die Zuweisung inzwischen, war es keiner. + if ! pveum acl list --output-format json 2>/dev/null | grep -q "$CLUPILOT_PVE_ROLE"; then + log 'Die Rollenzuweisung auf / ließ sich nicht setzen' + return 1 + fi + fi + + return 0 +} + +# Erzeugt den API-Token und gibt "=" aus. +# +# Das Geheimnis wird genau einmal gezeigt. Ein halb angelegter Token aus einem +# abgebrochenen früheren Lauf wird vorher entfernt, damit ein zweiter Lauf +# gleich ausgeht. +mint_proxmox_token() { + pveum user token remove "$CLUPILOT_PVE_USER" "$CLUPILOT_PVE_TOKEN_NAME" >/dev/null 2>&1 || true + + _out="$(pveum user token add "$CLUPILOT_PVE_USER" "$CLUPILOT_PVE_TOKEN_NAME" -privsep 0 --output-format json 2>/dev/null)" + [ -n "$_out" ] || return 1 + + _secret="$(json_field value "$_out")" + [ -n "$_secret" ] || return 1 + + _tokenid="$(json_field 'full-tokenid' "$_out")" + [ -n "$_tokenid" ] || _tokenid="${CLUPILOT_PVE_USER}!${CLUPILOT_PVE_TOKEN_NAME}" + + printf '%s=%s' "$_tokenid" "$_secret" +} + +# Der Fingerabdruck des SSH-Wirtsschlüssels. Nur zur Anzeige — die Plattform +# meldet sich nicht mehr per SSH an (Spec §6). +ssh_host_fingerprint() { + for _key in /etc/ssh/ssh_host_ed25519_key.pub /etc/ssh/ssh_host_rsa_key.pub; do + if [ -f "$_key" ]; then + ssh-keygen -lf "$_key" 2>/dev/null | awk '{ print $2; exit }' + return 0 + fi + done + printf '' +} + +# Die Eckdaten dieses Knotens, so wie `RegisterCapacity` sie liest. +# +# Der größte Speicher, der Platten aufnimmt — NICHT die Summe. Proxmox führt +# überlappende Pools (local und local-lvm auf derselben Platte), und zu +# summieren hieße, denselben Platz zweimal zu verkaufen. +node_total_gb() { + pvesm status --content images 2>/dev/null \ + | awk 'NR > 1 { if ($4 + 0 > max) max = $4 + 0 } END { printf "%d", max / 1048576 }' +} + +# Baut den Rumpf für POST /host/register. +render_register_payload() { + _api_token="$1" + _wg_pubkey="$2" + + printf '{"code":"%s","api_token":"%s","wg_pubkey":"%s","node":"%s","pve_version":"%s","total_gb":%s,"total_ram_mb":%s,"cpu_cores":%s,"ssh_host_key":"%s"}' \ + "$(json_escape "$CLUPILOT_CODE")" \ + "$(json_escape "$_api_token")" \ + "$(json_escape "$_wg_pubkey")" \ + "$(json_escape "$(hostname)")" \ + "$(json_escape "$(pveversion 2>/dev/null | head -1)")" \ + "$(node_total_gb)" \ + "$(awk '/^MemTotal:/ { printf "%d", $2 / 1024; exit }' /proc/meminfo)" \ + "$(nproc 2>/dev/null || echo 1)" \ + "$(json_escape "$(ssh_host_fingerprint)")" +} + +# POST /host/register — gibt die Antwort aus, oder 1. +post_registration() { + _payload="$1" + + if command -v curl >/dev/null 2>&1; then + curl -sS -f --connect-timeout 10 --max-time 60 \ + -H 'Content-Type: application/json' -H 'Accept: application/json' \ + -X POST --data "$_payload" \ + "${CLUPILOT_API}/host/register" 2>/dev/null + return $? + fi + + wget -q -O - --timeout=60 \ + --header='Content-Type: application/json' --header='Accept: application/json' \ + --post-data="$_payload" \ + "${CLUPILOT_API}/host/register" 2>/dev/null +} + +# --------------------------------------------------------------------------- +# Der Schlüsseltausch +# --------------------------------------------------------------------------- +# +# Task 6 lässt den Tunnel erst mit bewiesenem Handshake gelten. Hier wird genau +# dieser Tunnel im laufenden Betrieb auf einen neuen Schlüssel umgestellt — also +# gilt dieselbe Regel, und die Reihenfolge allein genügt nicht: sie sagt, WANN +# verworfen wird, nicht OB das Neue trägt. +# +# 1. neues Paar erzeugen, registrieren, Antwort haben +# 2. wg0 auf den neuen privaten Schlüssel umstellen +# 3. erneuten Handshake BEWEISEN +# 4. erst dann den alten verwerfen +# +# Kommt der Handshake nicht, wird zurückgestellt und laut gescheitert. Ein Host, +# der sich im letzten Schritt selbst aussperrt, ist der eine Fall, den niemand +# aus der Ferne repariert — und er passiert am Ende eines Laufs, der bis dahin +# alles richtig gemacht hat. +swap_wireguard_key() { + _new_private="$1" + _old_private="$CLUPILOT_WG_PRIVATE" + + CLUPILOT_WG_PRIVATE="$_new_private" + printf '%s\n' "$(render_wireguard_config)" > /etc/wireguard/wg0.conf + chmod 600 /etc/wireguard/wg0.conf + + systemctl restart wg-quick@wg0 >/dev/null 2>&1 || true + + if wireguard_handshake_proven 10; then + log 'Handshake mit dem neuen Schlüssel bewiesen' + return 0 + fi + + log 'Kein Handshake mit dem neuen Schlüssel — es wird auf den alten zurückgestellt' + CLUPILOT_WG_PRIVATE="$_old_private" + printf '%s\n' "$(render_wireguard_config)" > /etc/wireguard/wg0.conf + chmod 600 /etc/wireguard/wg0.conf + systemctl restart wg-quick@wg0 >/dev/null 2>&1 || true + + return 1 +} 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 c64204a..cd88c1e 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 @@ -52,6 +52,7 @@ | `deploy/bootstrap/lib/network.sh` | Brücke mit Selbstrücknahme, nftables | | `deploy/bootstrap/lib/traefik.sh` | Traefik: Binary, statische Konfiguration, Unit | | `deploy/bootstrap/lib/template.sh` | Die goldene Vorlage | +| `deploy/bootstrap/lib/register.sh` | Rolle, Token, Registrierung, Schlüsseltausch | | `deploy/bootstrap/assets/docker-compose.yml` | Die Compose-Datei, die in die Vorlage kommt | | `docs/runbooks/host-bootstrap.md` | Was zu tun ist, wenn das Skript stehenbleibt | @@ -439,7 +440,7 @@ fest, dass `VerifyVmTemplate` heute nur die **Existenz** prüft, nicht das Merkm ### Task 9: `registered` — Token übergeben -- [ ] **Step 1: Schreiben** +- [x] **Step 1: Schreiben** Proxmox-Rolle und Token erzeugen — **`Sys.Modify` nicht vergessen**, siehe `CreateAutomationToken.php`; ohne das stirbt jede Kundenbereitstellung am @@ -488,7 +489,7 @@ bedeutet** — und wo eines steht, steht der Grund daneben. meldet `provisioning.usable_host` als erfüllt. **Und der Tunnel steht danach noch** — nach dem Schlüsseltausch einmal `wg show` ansehen und einen frischen Handshake mit dem *neuen* Schlüssel sehen, nicht den alten Zählerstand. -- [ ] **Step 3: Committen.** `Hand over the token and let the console take it from here` +- [x] **Step 3: Committen.** `Hand over the token and let the console take it from here` ---