263 lines
8.1 KiB
Bash
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"
|
|
}
|