CluPilotCloud/deploy/bootstrap/lib/traefik.sh

263 lines
8.1 KiB
Bash

# 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"
}