Der Gateway bekommt seine Namen aus der .env und sucht die Zertifikate selbst
Compose mountet das Startskript aus Task 2 statt einer festen Caddyfile und gibt ihm VPN_TUNNEL_HOSTS mit; die alte vpn.Caddyfile entfaellt. update.sh haengt das vpn-Profil nur noch an VPN_INTERNAL_HOST, nicht mehr an VPN_CERT_PATH/VPN_KEY_PATH, weil der Gateway jeden Namen einzeln prueft statt mit leeren tls-Pfaden abzustuerzen. update-agent.sh ueberwacht zur Zertifikatserneuerung die Liste, die das Startskript schreibt, mit Rueckfall auf den alten Einzelpfad fuer einen Gateway von vor dieser Fassung.main
parent
3e47e02882
commit
58e0943d5b
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue