diff --git a/deploy/update-agent.sh b/deploy/update-agent.sh index d8b4273..71feb79 100755 --- a/deploy/update-agent.sh +++ b/deploy/update-agent.sh @@ -343,14 +343,28 @@ sync_vpn_certificate() { # einem echten Wirt existiert `.env` immer; das aendert hier nichts. [[ -f "$ROOT/.env" ]] || return 0 - path="$(sed -n 's/^VPN_CERT_PATH=//p' "$ROOT/.env" 2>/dev/null | tail -1)" - [[ -n "$path" ]] || return 0 + # Die Liste, die das Startskript beim Rendern geschrieben hat: genau die + # Zertifikate, die dieser Gateway wirklich geladen hat. Vorher stand hier + # EIN Pfad aus der .env — mit mehreren Namen haette eine Erneuerung von www. + # keinen Neustart ausgeloest, und der Tunnel liefe mit einem abgelaufenen + # Zertifikat weiter. Genau der Ausfall, den diese Funktion verhindern soll. + # + # Gelesen wird weiterhin INNERHALB des Gateways: Caddys Speicher gehoert + # seinem eigenen Dienstkonto, und dieser Agent laeuft unprivilegiert und + # koennte ihn nicht einmal durchqueren. + 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')" + + # Rueckfall auf den alten Weg, solange ein Gateway von vor dieser Fassung + # laeuft und die Liste noch nicht schreibt. Ohne ihn bliebe die Erneuerung + # zwischen Update und Neustart des Gateways unbeaufsichtigt. + 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')" + fi - # Read from INSIDE the gateway, not from the host. Caddy's storage belongs - # to its own service account and is deliberately left that way — this agent - # runs unprivileged and cannot even traverse it, so a host-side check would - # silently never fire and the gateway would serve an expired certificate. - stamp="$(docker compose exec -T vpn-gateway stat -c %Y "$path" 2>/dev/null | tr -d '\r\n')" [[ -n "$stamp" ]] || return 0 if [[ ! -f "$seen" ]] || [[ "$(cat "$seen" 2>/dev/null)" != "$stamp" ]]; then diff --git a/deploy/update.sh b/deploy/update.sh index 624e5aa..0a43129 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -258,7 +258,7 @@ reconcile_vpn_readiness() { # container-state check reports everything fine. # # Asked at the address the gateway actually serves. It binds to the hub - # address alone (see docker/caddy/vpn.Caddyfile), so nothing has ever + # address alone (see docker/caddy/vpn-entrypoint.sh), so nothing has ever # listened on 127.0.0.1 — the previous probe could only ever fail, and it # did: VPN_READY stayed false on a perfectly healthy tunnel, and because of # that the application withheld the resolver from every client config it @@ -304,12 +304,12 @@ reconcile_vpn_readiness() { fi } -# The VPN gateway and resolver live behind a compose profile, and behind a -# certificate. Enabling the profile on the hostname alone would start a Caddy -# with an empty tls directive — it fails to load, the internal console never -# comes up, and the application is meanwhile handing out client configs that -# point at a resolver nobody started. So: both, or neither, and BEFORE the -# services are brought up rather than after. +# The VPN gateway and resolver live behind a compose profile, keyed on +# VPN_INTERNAL_HOST alone: without a console name there is nothing for either +# service to do. The gateway renders its own configuration at start and skips +# any name whose certificate is missing (see docker/caddy/vpn-entrypoint.sh), +# so a missing certificate no longer has to hold the profile off — the code +# below only clears a certificate path that no longer matches the hostname. if ! grep -qE '^VPN_INTERNAL_HOST=..' .env 2>/dev/null; then # Cleared. Leaving the profile in place keeps both services running against # a placeholder hostname and stale certificate paths, so "empty disables it" @@ -334,8 +334,6 @@ if ! grep -qE '^VPN_INTERNAL_HOST=..' .env 2>/dev/null; then fi elif grep -qE '^VPN_INTERNAL_HOST=..' .env 2>/dev/null; then vpn_cert="$(sed -n 's/^VPN_CERT_PATH=//p' .env | tail -1)" - vpn_key="$(sed -n 's/^VPN_KEY_PATH=//p' .env | tail -1)" - vpn_host="$(sed -n 's/^VPN_INTERNAL_HOST=//p' .env | tail -1)" # The certificate has to belong to the CURRENT hostname. After a rename the @@ -343,41 +341,31 @@ elif grep -qE '^VPN_INTERNAL_HOST=..' .env 2>/dev/null; then # serving the previous name's certificate to clients resolving the new one. if [[ -n "$vpn_cert" && "$(basename "$vpn_cert")" != "${vpn_host}.crt" ]]; then sed -i '/^VPN_CERT_PATH=/d;/^VPN_KEY_PATH=/d;/^VPN_READY=/d' .env - vpn_cert=''; vpn_key='' in_app php artisan config:clear >/dev/null 2>&1 || true printf '\033[1;33m !\033[0m %s\n' "The tunnel certificate does not match VPN_INTERNAL_HOST — cleared." fi - if [[ -z "$vpn_cert" || -z "$vpn_key" ]]; then - # Both or neither: leaving the profile on with empty tls paths starts a - # Caddy that cannot load its configuration and crashes forever. - if grep -qE '^COMPOSE_PROFILES=.*vpn' .env 2>/dev/null; then - docker compose --profile vpn stop vpn-dns vpn-gateway >/dev/null 2>&1 || true - profiles="$(sed -n 's/^COMPOSE_PROFILES=//p' .env | tail -1)" - profiles="$(printf '%s' "$profiles" | tr ',' '\n' | grep -vx 'vpn' | grep -v '^$' | paste -sd, - || true)" - sed -i '/^COMPOSE_PROFILES=/d' .env - [[ -n "$profiles" ]] && printf 'COMPOSE_PROFILES=%s\n' "$profiles" >> .env - sed -i '/^VPN_READY=/d' .env + # Frueher hing das Profil an VPN_CERT_PATH/VPN_KEY_PATH: fehlten sie, wurde + # es abgeschaltet, weil ein Caddy mit leeren tls-Pfaden endlos abstuerzt. + # Der Gateway rendert seine Konfiguration inzwischen selbst und laesst jeden + # Namen aus, dessen Zertifikat fehlt — auch alle. Bleibt nur der + # Gesundheits-Port, und der ist genau das Signal, an dem VPN_READY haengt. + # + # Die Pfade selbst bleiben in der .env stehen und werden nicht mehr gelesen: + # install-agent.sh schreibt und loescht sie an mehreren Stellen, und ein + # Fehler darin sperrt den Betreiber aus dem Tunnel aus. + if ! grep -qE '^COMPOSE_PROFILES=.*vpn' .env; then + if grep -qE '^COMPOSE_PROFILES=' .env; then + sed -i 's/^COMPOSE_PROFILES=\(.*\)$/COMPOSE_PROFILES=\1,vpn/' .env + else + printf 'COMPOSE_PROFILES=vpn\n' >> .env fi - fi - - if [[ -n "$vpn_cert" && -n "$vpn_key" ]]; then - if ! grep -qE '^COMPOSE_PROFILES=.*vpn' .env; then - if grep -qE '^COMPOSE_PROFILES=' .env; then - sed -i 's/^COMPOSE_PROFILES=\(.*\)$/COMPOSE_PROFILES=\1,vpn/' .env - else - printf 'COMPOSE_PROFILES=vpn\n' >> .env - fi - log "Enabled the vpn compose profile" - # Started here, not left to the deploy below: the update may exit - # early as already deployed, and the profile would then be marked - # on with nothing running behind it. - docker compose --profile vpn up -d vpn-dns vpn-gateway >/dev/null 2>&1 || true - reconcile_vpn_readiness - fi - else - printf '\033[1;33m !\033[0m %s\n' "VPN_INTERNAL_HOST is set but the certificate paths are not." - printf ' %s\n' "The console is not reachable inside the tunnel. Run: sudo bash $(pwd)/deploy/install-agent.sh" + log "Enabled the vpn compose profile" + # Started here, not left to the deploy below: the update may exit + # early as already deployed, and the profile would then be marked + # on with nothing running behind it. + docker compose --profile vpn up -d vpn-dns vpn-gateway >/dev/null 2>&1 || true + reconcile_vpn_readiness fi fi diff --git a/docker-compose.yml b/docker-compose.yml index d7e83f9..94826a3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -218,14 +218,22 @@ services: profiles: ["vpn"] network_mode: "service:vpn-hub" volumes: - - ./docker/caddy/vpn.Caddyfile:/etc/caddy/Caddyfile:ro + # Das Startskript statt einer festen Konfiguration: die Namen sind je + # Installation andere, und zu jedem gehoert ein eigenes Zertifikat. + # Welche davon wirklich vorliegen, weiss erst der Container. + - ./docker/caddy/vpn-entrypoint.sh:/usr/local/bin/vpn-entrypoint.sh:ro # The certificate the public Caddy already obtains and renews. Read-only, # and shared rather than duplicated: a second ACME client would be a # second thing to renew, and the private address it would have to answer # on cannot satisfy an HTTP challenge anyway. - ${CADDY_DATA_DIR:-/var/lib/caddy/.local/share/caddy}:/certs:ro + entrypoint: ["sh", "/usr/local/bin/vpn-entrypoint.sh"] environment: VPN_INTERNAL_HOST: ${VPN_INTERNAL_HOST:-admin.invalid} + # Portal, Website und Statusseite — kommagetrennt, in derselben Reihenfolge + # wie in der .env. FILES_HOST steht bewusst nicht dabei: dort holt ein + # Server im Rettungssystem sein Archiv, und der ist nicht im Tunnel. + VPN_TUNNEL_HOSTS: ${APP_HOST:-},${SITE_HOST:-},${STATUS_HOST:-} # The same address the client is told to use as its resolver. Hard-coding # it here while the application reads a configurable one would produce a # config pointing at an address nothing listens on. @@ -234,8 +242,6 @@ services: # whether this gateway is really listening in the tunnel's namespace. # Never published; 10.66.0.1 exists only inside the tunnel. VPN_HEALTH_PORT: ${VPN_HEALTH_PORT:-8081} - VPN_CERT_PATH: ${VPN_CERT_PATH:-} - VPN_KEY_PATH: ${VPN_KEY_PATH:-} depends_on: - vpn-hub - app diff --git a/docker/caddy/vpn.Caddyfile b/docker/caddy/vpn.Caddyfile deleted file mode 100644 index cf115b5..0000000 --- a/docker/caddy/vpn.Caddyfile +++ /dev/null @@ -1,53 +0,0 @@ -# The console, served on the WireGuard hub address. -# -# Runs inside the provisioning container's network namespace, so it listens on -# 10.66.0.1 — the tunnel address itself. A VPN client therefore needs no route -# beyond the management subnet it already has, and the request arrives at the -# application with its real 10.66.0.x source, which is what the console's own -# IP allowlist checks. -# -# The certificate is the one the public Caddy already obtains and renews for -# this hostname. A certificate is bound to the NAME, not to the address that -# serves it, so the same file is valid here. Shared read-only rather than -# duplicated: a second ACME client would be a second thing to renew, and a -# private address cannot satisfy an HTTP challenge in any case. -{ - admin off - auto_https off -} - -https://{$VPN_INTERNAL_HOST}:443 { - bind {$VPN_HUB_ADDRESS} - tls {$VPN_CERT_PATH} {$VPN_KEY_PATH} - - # To the application directly. Resolvable because this container shares the - # provisioning container's namespace, which is on the compose network. - reverse_proxy app:80 { - header_up X-Forwarded-For {remote_host} - header_up X-Forwarded-Proto https - header_up Host {host} - } -} - -# A health port the deployment can ask, on the hub address and nowhere else. -# -# The site above matches on the console's HOSTNAME. A probe to the bare address -# sends no SNI, is offered no certificate and fails the handshake — which is -# indistinguishable from the gateway being down, and that is precisely the -# mistake that left VPN_READY false on a healthy tunnel. Plain HTTP on a -# separate port removes TLS, SNI and name resolution from the question and -# answers only what is being asked: is this gateway listening, in this network -# namespace, right now. -# -# Not a hole: 10.66.0.1 exists only inside the tunnel, and this port is never -# published. Caddy also refuses to start when the certificate above cannot be -# read, so a health port that answers proves the whole file loaded. -http://{$VPN_HUB_ADDRESS}:{$VPN_HEALTH_PORT:8081} { - # bind, not merely a matching hostname: without it Caddy listens on every - # interface in the namespace and only MATCHES on the address. Nothing is - # published, so it was not reachable from outside either way — but "on the - # hub address and nowhere else" should be what the file actually does. - bind {$VPN_HUB_ADDRESS} - respond /healthz 204 - respond 404 -}