Fix-Runde 1: der Update-Agent stirbt nicht mehr an einem gestoppten Gateway, und der Gesundheits-Port luegt nicht mehr

Kritisch: sync_vpn_certificate() rief docker compose exec als blanke
Zuweisung unter set -Eeuo pipefail auf — schlaegt das fehl (kein
laufender vpn-gateway), beendet set -e den ganzen Agenten, still,
nachdem write_alive schon lief. Beide Zuweisungen bekommen jetzt
`|| stamp=''`, mit Begruendung im Kommentar.

Wichtig: der Gesundheits-Port antwortete immer mit 204, auch wenn das
Startskript fuer den Konsolennamen kein Zertifikat fand und den
Gateway ohne jede Seite auf 443 rendert. VPN_READY wurde dann wahr,
und ausgegebene Client-Konfigurationen nannten einen Resolver, der die
Verbindung ablehnt. vpn-entrypoint.sh prueft jetzt, ob die
Konsolen-Seite wirklich gerendert wurde, und antwortet sonst mit 503 —
wget --spider (gegen das echte caddy:2-alpine-Image verifiziert, nicht
angenommen) behandelt das als Fehlschlag, vpn_ready bleibt false, und
die bestehende Warnung in update.sh greift wieder: sie ist nicht
verschwunden, sondern hierher gewandert. Zwei Tests in
VpnGatewayConfigTest decken beide Richtungen ab; der alte Test mit der
jetzt falschen Annahme "unabhaengig von jedem Zertifikat" wich dem
Test fuer den Fall mit Zertifikat.

Kleinigkeit: ein Satz im Kommentar von sync_vpn_certificate() haelt
fest, dass die Zertifikatsliste einmalig beim Start geschrieben wird
und ein nachtraeglich ausgestelltes Zertifikat erst den naechsten
Neustart des Gateways sieht.
main
nexxo 2026-08-04 18:18:58 +02:00
parent 4dbbed72e2
commit 5967c56d16
4 changed files with 77 additions and 15 deletions

View File

@ -349,12 +349,28 @@ sync_vpn_certificate() {
# keinen Neustart ausgeloest, und der Tunnel liefe mit einem abgelaufenen
# Zertifikat weiter. Genau der Ausfall, den diese Funktion verhindern soll.
#
# Geschrieben EINMAL beim Start des Gateways, nicht laufend nachgefuehrt: ein
# Name, dessen Zertifikat erst NACH diesem Start ausgestellt wird, bleibt bis
# zum naechsten Neustart des Gateways unbedient — diese Funktion sieht ihn
# nicht, weil er nie in der Liste stand. Kein Rueckschritt gegenueber vorher,
# aber auch keine laufende Ueberwachung neuer Namen.
#
# Gelesen wird weiterhin INNERHALB des Gateways: Caddys Speicher gehoert
# seinem eigenen Dienstkonto, und dieser Agent laeuft unprivilegiert und
# koennte ihn nicht einmal durchqueren.
#
# `|| stamp=''`, nicht bloss eine Zuweisung: genau wie beim `sed` oben stirbt
# unter `set -Eeuo pipefail` sonst der GANZE Agent an dieser Stelle, wenn
# `docker compose exec` fehlschlaegt — und das tut es auf jedem Wirt ohne
# laufenden vpn-gateway (nie konfiguriert, gestoppt, abgestuerzt, mitten in
# einem Deploy neu erzeugt). `write_alive running` ist zu diesem Zeitpunkt
# schon gelaufen, der Takt saehe also gesund aus, waehrend der Agent seinen
# eigentlichen Zweck — die Anfrage vom Bedienfeld zu lesen — nie wieder
# erreicht. Frueher konnte das nicht passieren: das erste Kommando war der
# `sed` auf `.env`, hinter genau dieser Wache. Dieser Aufruf steht davor.
stamp="$(docker compose exec -T vpn-gateway sh -c \
'xargs -r stat -c %Y < /tmp/vpn-certs.list 2>/dev/null | sort | tr "\n" ","' \
2>/dev/null | tr -d '\r\n')"
2>/dev/null | tr -d '\r\n')" || stamp=''
# Rueckfall auf den alten Weg, solange ein Gateway von vor dieser Fassung
# laeuft und die Liste noch nicht schreibt. Ohne ihn bliebe die Erneuerung
@ -362,7 +378,9 @@ sync_vpn_certificate() {
if [[ -z "$stamp" ]]; then
path="$(sed -n 's/^VPN_CERT_PATH=//p' "$ROOT/.env" 2>/dev/null | tail -1)"
[[ -n "$path" ]] || return 0
stamp="$(docker compose exec -T vpn-gateway stat -c %Y "$path" 2>/dev/null | tr -d '\r\n')"
# Dieselbe Wache wie oben: ein Gateway, das gerade nicht laeuft, darf den
# Agenten nicht mitreissen.
stamp="$(docker compose exec -T vpn-gateway stat -c %Y "$path" 2>/dev/null | tr -d '\r\n')" || stamp=''
fi
[[ -n "$stamp" ]] || return 0

View File

@ -268,9 +268,23 @@ reconcile_vpn_readiness() {
# Over plain HTTP on the health port, not HTTPS: the TLS site matches on the
# console's hostname, so a request to the bare address presents no SNI, gets
# no certificate and fails the handshake — which looks exactly like the
# outage this is meant to detect. Caddy refuses to start at all if the
# certificate is unreadable, so a health port that answers still proves the
# whole gateway loaded.
# outage this is meant to detect.
#
# The health port itself now answers 204 only when the console's certificate
# was actually found and its site rendered (see docker/caddy/vpn-entrypoint.sh)
# — 503 otherwise. That used to read "Caddy refuses to start at all if the
# certificate is unreadable, so a health port that answers proves the whole
# gateway loaded", which was true only of the fixed configuration: this
# gateway now starts and serves the health port regardless, skipping any site
# whose certificate is missing. A 204 that only meant "the process is up"
# would no longer be a fit signal for VPN_READY — a client would be handed a
# resolver pointing at a console the gateway isn't actually serving on 443.
# `wget --spider` (and the `-O /dev/null` fallback) both fail on a 503 —
# checked directly against this image (`caddy:2-alpine`'s BusyBox wget), not
# assumed — so a missing console certificate lands exactly here: vpn_ready
# stays false and the warning below fires, the same one that used to run when
# the profile was disabled outright. The message did not go away with that
# branch; it moved here.
# The same variable compose hands the gateway, so the two cannot drift.
local hub port health
hub="$(sed -n 's/^CLUPILOT_WG_HUB_ADDRESS=//p' .env | tail -1)"

View File

@ -80,14 +80,28 @@ echo "${VPN_TUNNEL_HOSTS:-}" | tr ',' '\n' | while read -r host; do
emit_site "$host"
done
# Der Gesundheits-Port, ohne TLS und ohne Namen. Daran hängt VPN_READY und damit,
# ob ausgegebene Client-Konfigurationen den Resolver überhaupt nennen — er darf
# deshalb von keinem Zertifikat abhängen.
# Antwortet der Gesundheits-Port mit 204, gilt der Tunnel als BEREIT — daran
# haengt, ob ausgegebene Client-Konfigurationen den Resolver ueberhaupt nennen.
# Er darf deshalb nicht bloss sagen „dieser Gateway laeuft": ohne Zertifikat fuer
# den Konsolennamen rendert dieses Skript einen Gateway, der auf 443 gar nichts
# bedient — und ein Client bekaeme einen Resolver genannt, der ihn auf eine
# Adresse schickt, die die Verbindung ablehnt. Frueher war das unmoeglich, weil
# Caddy ohne lesbares Zertifikat ueberhaupt nicht startete; genau diese Annahme
# hat dieses Skript aufgehoben.
if [ -n "${VPN_INTERNAL_HOST:-}" ] && grep -q "^https://${VPN_INTERNAL_HOST}:443 {" "$OUT"; then
HEALTH_CODE=204
else
HEALTH_CODE=503
fi
# Der Gesundheits-Port selbst, ohne TLS und ohne Namen: ein Client, der nur den
# Resolver braucht, soll nicht erst ein Zertifikat vorweisen muessen, um zu
# erfahren, ob der Tunnel steht.
{
echo ""
echo "http://${HUB}:${HEALTH} {"
echo " bind ${HUB}"
echo " respond /healthz 204"
echo " respond /healthz ${HEALTH_CODE}"
echo " respond 404"
echo "}"
} >> "$OUT"

View File

@ -85,20 +85,36 @@ it('behält die Konsole, auch wenn sonst nichts ein Zertifikat hat', function ()
->and($config)->not->toContain('www.clupilot.test');
});
it('schreibt den Gesundheits-Port unabhängig von jedem Zertifikat', function () {
// Daran hängt VPN_READY, und damit ob Client-Konfigurationen den Resolver
// überhaupt nennen. Ein Gesundheits-Port, der ein Zertifikat braucht, wäre
// genau die Attrappe, die schon einmal VPN_READY auf einer gesunden Anlage
// false stehen ließ.
it('schreibt den Gesundheits-Port als bereit, wenn die Konsole ein Zertifikat hat', function () {
// Genau das Signal, an dem VPN_READY hängt: 204 heißt, ein Client darf den
// ausgegebenen Resolver benutzen, weil die Konsole auf 443 wirklich bedient
// wird — nicht bloß, dass irgendein Caddy-Prozess läuft.
$config = renderVpnConfig([
'VPN_INTERNAL_HOST' => 'admin.clupilot.test',
'VPN_TUNNEL_HOSTS' => '',
], []);
], ['admin.clupilot.test']);
expect($config)->toContain('http://10.66.0.1:8081')
->and($config)->toContain('respond /healthz 204');
});
it('schreibt den Gesundheits-Port als NICHT bereit, wenn die Konsole kein Zertifikat hat', function () {
// Ohne Zertifikat für die Konsole bedient dieser Gateway auf 443 gar
// nichts — ein 204 hier wäre eine Falschmeldung: VPN_READY würde wahr,
// und ein Client bekäme einen Resolver genannt, der ihn auf eine Adresse
// schickt, die die Verbindung ablehnt. Andere Zertifikate (hier: app.)
// dürfen daran nichts ändern — es geht um die KONSOLE, nicht um "irgendein
// Zertifikat", das war genau die falsche Annahme im alten Test.
$config = renderVpnConfig([
'VPN_INTERNAL_HOST' => 'admin.clupilot.test',
'VPN_TUNNEL_HOSTS' => 'app.clupilot.test',
], ['app.clupilot.test']);
expect($config)->toContain('http://10.66.0.1:8081')
->and($config)->toContain('respond /healthz 503')
->and($config)->not->toContain('respond /healthz 204');
});
it('nimmt keinen Namen auf, den niemand konfiguriert hat', function () {
$config = renderVpnConfig([
'VPN_INTERNAL_HOST' => 'admin.clupilot.test',