diff --git a/deploy/bootstrap/clupilot-bootstrap.sh b/deploy/bootstrap/clupilot-bootstrap.sh index 2b4d3ea..7bf05ae 100755 --- a/deploy/bootstrap/clupilot-bootstrap.sh +++ b/deploy/bootstrap/clupilot-bootstrap.sh @@ -85,6 +85,11 @@ CLUPILOT_ZFS_ARC_MAX_MB="${CLUPILOT_ZFS_ARC_MAX_MB:-2048}" # 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. +CLUPILOT_ACME_EMAIL="${CLUPILOT_ACME_EMAIL:-ssl@clupilot.cloud}" + usage() { cat <<'TEXT' clupilot-bootstrap.sh — macht aus einer Maschine im Rettungssystem einen @@ -735,6 +740,51 @@ section_wireguard_joined() { 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 +} + main() { parse_arguments "$@" @@ -745,6 +795,8 @@ main() { . "${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" init_reporting if [ "$CLUPILOT_RESUMING" = '1' ]; then @@ -787,7 +839,10 @@ main() { # Ab hier existiert ein Weg. Die ganze Vorgeschichte geht jetzt raus. flush_reports - # run_section traefik_running section_traefik_running # Task 7 + if ! run_section traefik_running section_traefik_running; then + die 'Abbruch: ohne Traefik erreicht kein Kunde je seine Instanz.' + fi + # run_section template_built section_template_built # Task 8 # run_section registered section_registered # Task 9 diff --git a/deploy/bootstrap/lib/traefik.sh b/deploy/bootstrap/lib/traefik.sh new file mode 100644 index 0000000..94ff0c8 --- /dev/null +++ b/deploy/bootstrap/lib/traefik.sh @@ -0,0 +1,262 @@ +# shellcheck shell=sh +# +# Traefik: Binary plus systemd-Unit. **Kein Docker auf dem Hypervisor** (Handoff +# §1) — nftables bleibt einziger Eigentümer der Host-Firewallregeln, und ein +# Docker-Dienst brächte seine eigene Kette mit. +# +# --------------------------------------------------------------------------- +# Die Namen, die passen müssen +# --------------------------------------------------------------------------- +# +# `SshTraefikWriter::render()` gibt aus, was der Routen-Endpunkt später liefert, +# und die statische Konfiguration hier muss dazu passen. Nachgelesen, nicht +# geraten: +# +# entryPoints: ["websecure"] +# tls.certResolver: "letsencrypt" +# service -> loadBalancer.servers[].url: "http://:80" +# +# Heißt der Entrypoint hier anders, laufen die Router ins Leere und Traefik +# meldet trotzdem einen sauberen Start. +# +# --------------------------------------------------------------------------- +# Reihenfolge: der Token kommt erst später +# --------------------------------------------------------------------------- +# +# Der `http`-Provider braucht den dauerhaften Host-Token, und den gibt es erst +# mit `POST /host/register` in Abschnitt `registered`. Die statische +# Konfiguration wird deshalb ZWEIMAL geschrieben: hier mit leerem Token, damit +# der Dienst steht und 80/443 belegt sind, und in Task 9 noch einmal mit dem +# echten. Erst dort lässt sich beweisen, dass eine Routentabelle wirklich +# abgerufen wurde. + +CLUPILOT_TRAEFIK_BIN='/usr/local/bin/traefik' +CLUPILOT_TRAEFIK_DIR='/etc/traefik' +CLUPILOT_TRAEFIK_TOKEN_FILE='/etc/traefik/host-token' +CLUPILOT_TRAEFIK_API='http://127.0.0.1:8080' + +# Neueste stabile v3 aus der Release-Liste. +# +# Nicht fest verdrahtet, aus demselben Grund wie bei der ISO: eine feste Nummer +# ist in ein paar Monaten eine 404 mitten in einer Übernahme. +discover_traefik_version() { + _json="$(http_get 'https://api.github.com/repos/traefik/traefik/releases/latest' || true)" + [ -n "$_json" ] || return 1 + + printf '%s' "$_json" \ + | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\(v3[0-9.]*\)".*/\1/p' \ + | head -1 +} + +# Lädt das Binary und prüft es gegen die Prüfsummendatei der Ausgabe. +# +# Dieselbe Begründung wie bei der ISO: was auf 80 und 443 lauscht und die +# Zertifikate aller Kunden hält, wird nicht ungeprüft aus dem Netz übernommen. +install_traefik_binary() { + _version="$1" + _plain="${_version#v}" + _base="https://github.com/traefik/traefik/releases/download/${_version}" + _archive="traefik_${_version}_linux_amd64.tar.gz" + _sums="traefik_${_version}_checksums.txt" + + _tmp="${CLUPILOT_WORK_DIR}/traefik" + rm -rf "$_tmp" + mkdir -p "$_tmp" + + if ! http_download "${_base}/${_archive}" "${_tmp}/${_archive}"; then + log "Traefik ${_version} nicht ladbar" + return 1 + fi + + _sumfile="$(http_get "${_base}/${_sums}" || true)" + if [ -z "$_sumfile" ]; then + log "Prüfsummendatei ${_sums} nicht abrufbar" + return 1 + fi + + _expected="$(printf '%s\n' "$_sumfile" | awk -v n="$_archive" '$2 == n || $2 == "*" n { print $1; exit }')" + if [ -z "$_expected" ]; then + log "keine Prüfsumme für ${_archive}" + return 1 + fi + + _actual="$(sha256sum "${_tmp}/${_archive}" | awk '{ print $1 }')" + if [ "$_actual" != "$_expected" ]; then + log "Traefik-Prüfsumme stimmt nicht: erwartet ${_expected}, bekommen ${_actual}" + return 1 + fi + + tar xzf "${_tmp}/${_archive}" -C "$_tmp" traefik + install -m 755 "${_tmp}/traefik" "$CLUPILOT_TRAEFIK_BIN" + rm -rf "$_tmp" + + log "Traefik ${_plain} installiert, Prüfsumme stimmt" + return 0 +} + +# Die statische Konfiguration. +# +# `token` darf leer sein — dann steht der Dienst, holt aber keine Tabelle. Das +# ist der Zustand zwischen Abschnitt 7 und Abschnitt 9. +write_traefik_static_config() { + _token="${1:-}" + _endpoint="${CLUPILOT_API}/host/routes" + + mkdir -p "$CLUPILOT_TRAEFIK_DIR" + chmod 755 "$CLUPILOT_TRAEFIK_DIR" + + # acme.json hält die privaten Schlüssel aller Kundenzertifikate. Traefik + # weigert sich, sie zu benutzen, wenn die Rechte weiter sind als 600 — und + # das ist gut so. + if [ ! -f "${CLUPILOT_TRAEFIK_DIR}/acme.json" ]; then + : > "${CLUPILOT_TRAEFIK_DIR}/acme.json" + fi + chmod 600 "${CLUPILOT_TRAEFIK_DIR}/acme.json" + + cat > "${CLUPILOT_TRAEFIK_DIR}/traefik.yml" < "$CLUPILOT_TRAEFIK_TOKEN_FILE" + chmod 600 "$CLUPILOT_TRAEFIK_TOKEN_FILE" + fi +} + +write_traefik_unit() { + cat > /etc/systemd/system/traefik.service </dev/null 2>&1; then + return 0 + fi + _tries=$((_tries - 1)) + sleep 2 + done + return 1 +} + +# Lauschen 80 und 443 wirklich? +# +# „Der Dienst läuft" und „der Port ist belegt" sind zwei Aussagen. Traefik +# startet auch dann sauber, wenn ein Entrypoint wegen eines Tippfehlers gar +# nicht angelegt wurde. +traefik_ports_bound() { + for _port in 80 443; do + if ! ss -ltn "sport = :${_port}" 2>/dev/null | grep -q LISTEN; then + log "Port ${_port} ist nicht belegt" + return 1 + fi + done + return 0 +} + +# Holt der Host seine Routentabelle wirklich? +# +# Direkt gefragt, mit demselben Token und derselben Adresse, die in der +# statischen Konfiguration stehen. Über Traefiks eigene Router zu gehen wäre +# hier kein Beweis: ein frischer Host hat noch keine Kunden, also ist die +# Tabelle mit Recht leer, und „null Router" hieße dann sowohl „alles gut" als +# auch „nichts abgerufen". +host_routes_reachable() { + _token="$1" + [ -n "$_token" ] || return 1 + + if command -v curl >/dev/null 2>&1; then + curl -sS -f --connect-timeout 5 --max-time 15 \ + -H "Authorization: Bearer ${_token}" \ + "${CLUPILOT_API}/host/routes" >/dev/null 2>&1 + return $? + fi + + wget -q -O /dev/null --timeout=15 \ + --header="Authorization: Bearer ${_token}" \ + "${CLUPILOT_API}/host/routes" +} 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 f3afcc5..4369b68 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 @@ -50,6 +50,7 @@ | `deploy/bootstrap/lib/report.sh` | Melden und Nachreichen. Kennt als Einziges den Weg zu CluPilot. | | `deploy/bootstrap/lib/proxmox.sh` | Die Installation (`debian_installed`, `rebooted`) und die Paketquellen (`proxmox_installed`) | | `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/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 | @@ -369,7 +370,7 @@ Sobald der Handshake steht: `flush_reports` — alles bis hierher wird nachgerei ### Task 7: `traefik_running` -- [ ] **Step 1: Schreiben** +- [x] **Step 1: Schreiben** Binary plus systemd-Unit, **kein Docker**. Statische Konfiguration mit `websecure`-Entrypoint und `letsencrypt`-certResolver — **deren Namen zu dem passen @@ -384,9 +385,26 @@ Ping-Endpunkt antwortet, und **die erste Routentabelle wurde tatsächlich geholt Ein Host, der Traefik installiert hat, aber nie eine Tabelle bekam, ist nicht fertig. +> **Eine Abhängigkeit, die dieser Plan nicht aufgelöst hatte.** Der +> `http`-Provider braucht den **dauerhaften Host-Token**, und den gibt es erst +> mit `POST /host/register` — also in Abschnitt `registered`, zwei Abschnitte +> später. Der Beweis „die erste Tabelle wurde geholt" lässt sich hier deshalb +> nicht führen. +> +> Aufgelöst, ohne die Abschnittsschlüssel anzufassen: die statische +> Konfiguration wird **zweimal** geschrieben. Hier mit leerem Token — der Dienst +> steht, hält 80 und 443 und antwortet am Ping —, in Task 9 noch einmal mit dem +> echten, gefolgt von einem Neustart und dem Abruf. **Der Beweis entfällt nicht, +> er wandert an die Stelle, an der er zu führen ist.** +> +> Der Abruf wird dort direkt gemacht, mit demselben Token und derselben Adresse +> aus der Konfiguration — nicht über Traefiks eigene Router. Ein frischer Host +> hat noch keine Kunden, seine Tabelle ist also mit Recht leer, und „null +> Router" hieße sowohl „alles in Ordnung" als auch „nichts abgerufen". + - [ ] **Step 2: Auf echter Hardware prüfen**, einschließlich eines abgerufenen Routensatzes. -- [ ] **Step 3: Committen.** `Run Traefik as a service that fetches its own routes` +- [x] **Step 3: Committen.** `Run Traefik as a service that fetches its own routes` ---