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
parent
c345f21e7d
commit
a25e9c2fe6
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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`
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue