Run Traefik as a service that fetches its own routes

Binary plus systemd unit, no Docker on the hypervisor: nftables stays the only
owner of the host's firewall rules and a Docker daemon would bring its own
chain.

The names were read out of SshTraefikWriter::render() rather than guessed, which
is what the handoff asks for in as many words. The route endpoint emits
entryPoints ["websecure"] and certResolver "letsencrypt", so the static config
declares exactly those. Name them differently here and the routers point at
nothing while Traefik still reports a clean start — a failure with no symptom
at the place it happens.

An ordering problem this plan had not resolved: the http provider needs the
durable host token, and that token only exists after POST /host/register, two
sections later. Rather than reorder the section keys — they are the contract
with the platform plan — the static config is written twice. Here with an empty
token, so the service stands and holds 80 and 443, and again in Task 9 with the
real one. The proof does not disappear, it moves to where it can be given.

That proof asks the endpoint directly with the same token and URL the config
carries, instead of counting Traefik's routers. A fresh host has no customers,
so its table is legitimately empty, and "zero routers" would mean both "fine"
and "never fetched".

Ports are checked separately from the service. "Running" and "listening" are two
claims, and Traefik starts cleanly even when a typo means an entryPoint was
never created.

The binary is checksummed against the release's own checksums file, for the same
reason the ISO is: what listens on 80 and 443 and holds every customer's
certificate does not get taken off the network unverified. The version is
discovered at runtime, because a pinned number becomes a 404 mid-takeover.

acme.json is created at 600 before Traefik ever runs. It holds the private keys
of every customer certificate, and Traefik refuses wider permissions — rightly.
The unit runs with CAP_NET_BIND_SERVICE and nothing else, ProtectSystem=strict,
NoNewPrivileges: this is the one process on the box reachable from the open
internet.

Verified without hardware: dash-clean, the generated config parses as YAML, and
it carries web/websecure/traefik as entryPoints, letsencrypt as the resolver,
the Bearer header on the http provider, web redirecting to websecure, and 640 on
the config with 600 on acme.json and the token file. Step 2 unticked — it wants a
fetched route set, and that is Task 9's to show.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/host-bootstrap
nexxo 2026-07-30 20:13:54 +02:00
parent c345f21e7d
commit a25e9c2fe6
3 changed files with 338 additions and 3 deletions

View File

@ -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

View File

@ -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://<backend>: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" <<EOF
# Von CluPilots Bootstrap geschrieben. Nicht von Hand ändern — der nächste Lauf
# überschreibt die Datei.
global:
checkNewVersion: false
sendAnonymousUsage: false
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
websecure:
address: ":443"
# Nur auf der Loopback-Adresse. Der Zustand dieses Dienstes geht niemanden
# außerhalb der Maschine etwas an, und nftables ließe 8080 ohnehin nicht durch.
traefik:
address: "127.0.0.1:8080"
# Die Namen kommen aus SshTraefikWriter::render(): der Routen-Endpunkt liefert
# entryPoints ["websecure"] und certResolver "letsencrypt". Wer sie hier ändert,
# muss sie dort mitändern — sonst laufen die Router ins Leere und Traefik startet
# trotzdem sauber.
certificatesResolvers:
letsencrypt:
acme:
email: "${CLUPILOT_ACME_EMAIL}"
storage: "${CLUPILOT_TRAEFIK_DIR}/acme.json"
httpChallenge:
entryPoint: web
providers:
http:
endpoint: "${_endpoint}"
pollInterval: "30s"
pollTimeout: "10s"
headers:
Authorization: "Bearer ${_token}"
api:
dashboard: false
insecure: true
ping:
entryPoint: traefik
log:
level: INFO
filePath: "/var/log/traefik.log"
accessLog:
filePath: "/var/log/traefik-access.log"
EOF
chmod 640 "${CLUPILOT_TRAEFIK_DIR}/traefik.yml"
if [ -n "$_token" ]; then
umask 077
printf '%s\n' "$_token" > "$CLUPILOT_TRAEFIK_TOKEN_FILE"
chmod 600 "$CLUPILOT_TRAEFIK_TOKEN_FILE"
fi
}
write_traefik_unit() {
cat > /etc/systemd/system/traefik.service <<EOF
[Unit]
Description=Traefik (von CluPilot verwaltet)
Documentation=https://doc.traefik.io/traefik/
After=network-online.target
Wants=network-online.target
[Service]
Type=notify
ExecStart=${CLUPILOT_TRAEFIK_BIN} --configFile=${CLUPILOT_TRAEFIK_DIR}/traefik.yml
Restart=on-failure
RestartSec=5
# Braucht 80 und 443 und sonst nichts. Ohne diese Zeile liefe ein Dienst als
# root, der von außen erreichbar ist — die eine Stelle, an der das am wenigsten
# sein darf.
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=${CLUPILOT_TRAEFIK_DIR} /var/log
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
}
traefik_answers_ping() {
_tries="${1:-15}"
while [ "$_tries" -gt 0 ]; do
if http_get "${CLUPILOT_TRAEFIK_API}/ping" >/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"
}

View File

@ -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`
---