diff --git a/deploy/update-agent.sh b/deploy/update-agent.sh index 71feb79..dba71e3 100755 --- a/deploy/update-agent.sh +++ b/deploy/update-agent.sh @@ -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 diff --git a/deploy/update.sh b/deploy/update.sh index 0a43129..5f29a58 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -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)" diff --git a/docker/caddy/vpn-entrypoint.sh b/docker/caddy/vpn-entrypoint.sh index e12984b..7531c2d 100755 --- a/docker/caddy/vpn-entrypoint.sh +++ b/docker/caddy/vpn-entrypoint.sh @@ -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" diff --git a/tests/Feature/VpnGatewayConfigTest.php b/tests/Feature/VpnGatewayConfigTest.php index 3af1448..17d3926 100644 --- a/tests/Feature/VpnGatewayConfigTest.php +++ b/tests/Feature/VpnGatewayConfigTest.php @@ -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',