diff --git a/docker/caddy/vpn-entrypoint.sh b/docker/caddy/vpn-entrypoint.sh new file mode 100755 index 0000000..e12984b --- /dev/null +++ b/docker/caddy/vpn-entrypoint.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env sh +# +# Erzeugt die Konfiguration des Tunnel-Gateways beim Start und startet Caddy. +# +# Warum erzeugt statt fest hinterlegt: die Namen stehen in der .env und sind je +# Installation andere, und zu jedem gehört ein eigenes Zertifikat. Caddy startet +# NICHT, wenn eine in `tls` genannte Datei fehlt — ein noch nicht ausgestelltes +# Zertifikat für www. nähme damit den Tunnel-Zugang zur Konsole mit, und das ist +# der Weg, auf dem sich ein ausgesperrter Betreiber zurückholt. Deshalb wird +# jeder Name einzeln geprüft und ausgelassen, wenn sein Zertifikat fehlt. +# +# Die Zertifikate sind die, die der öffentliche Caddy ohnehin erneuert. Ein +# Zertifikat hängt am NAMEN, nicht an der Adresse, die ihn ausliefert. +set -eu + +HUB="${VPN_HUB_ADDRESS:-10.66.0.1}" +HEALTH="${VPN_HEALTH_PORT:-8081}" +CERT_DIR="${VPN_CERT_DIR:-/certs}" +OUT="${VPN_CONFIG_OUT:-/tmp/vpn.Caddyfile}" + +# Die Zertifikate, die dieser Lauf wirklich geladen hat. Der Update-Agent +# ueberwacht sie und startet den Gateway nach einer Erneuerung neu — Caddys +# `tls` liest die Datei EINMAL beim Start, und ohne Neustart liefe der Tunnel +# danach mit einem abgelaufenen Zertifikat weiter, ausgerechnet fuer die +# einzigen Leute, die die Konsole noch erreichen. +CERT_LIST="${VPN_CERT_LIST:-/tmp/vpn-certs.list}" +: > "$CERT_LIST" + +{ + echo '{' + echo ' admin off' + echo ' auto_https off' + echo '}' +} > "$OUT" + +# Der Konsolenname ZUERST, und getrennt von der Liste: er ist der einzige, ohne +# den der Gateway keinen Zweck hat. +emit_site() { + name="$1" + # `-print -quit`, nicht `| head -1`: eine Pipe, aus der head aussteigt, + # waehrend find noch schreibt, liefert SIGPIPE — install-agent.sh hat sich + # daran schon einmal selbst beendet. `|| true`, damit ein leeres Ergebnis + # unter `set -e` kein Abbruch ist. + crt="$(find "$CERT_DIR" -name "${name}.crt" -print -quit 2>/dev/null || true)" + + if [ -z "$crt" ] || [ ! -f "${crt%.crt}.key" ]; then + echo " ausgelassen: $name — kein Zertifikat unter $CERT_DIR" >&2 + return 0 + fi + + { + echo "" + echo "https://${name}:443 {" + echo " bind ${HUB}" + echo " tls ${crt} ${crt%.crt}.key" + echo " reverse_proxy app:80 {" + echo " header_up X-Forwarded-For {remote_host}" + echo " header_up X-Forwarded-Proto https" + echo " header_up Host {host}" + echo " }" + echo "}" + } >> "$OUT" + + echo "$crt" >> "$CERT_LIST" +} + +# `if`, nicht `[ … ] && …`. Bei leerem Wert gibt die AND-OR-Liste 1 zurueck, und +# genau diese Konstruktion hat install-agent.sh unter `set -e` schon einmal +# beendet — dort als letzte Anweisung einer Funktion. Hier waere sie geprueft +# unschaedlich, aber die Regel steht im Repo und eine Ausnahme davon muesste man +# jedem Nachfolger erklaeren. +if [ -n "${VPN_INTERNAL_HOST:-}" ]; then + emit_site "$VPN_INTERNAL_HOST" +fi + +# Portal, Website und Statusseite. files. steht hier nie drin: dort holt ein +# Server im Rettungssystem sein Archiv, und der ist nicht im Tunnel. +echo "${VPN_TUNNEL_HOSTS:-}" | tr ',' '\n' | while read -r host; do + [ -n "$host" ] || continue + 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. +{ + echo "" + echo "http://${HUB}:${HEALTH} {" + echo " bind ${HUB}" + echo " respond /healthz 204" + echo " respond 404" + echo "}" +} >> "$OUT" + +# Nur rendern, für den Test: er prüft, was das Skript AUSLÄSST, und braucht +# dafür kein laufendes Caddy. +if [ -n "${VPN_RENDER_ONLY:-}" ]; then + exit 0 +fi + +exec caddy run --config "$OUT" --adapter caddyfile diff --git a/tests/Feature/VpnGatewayConfigTest.php b/tests/Feature/VpnGatewayConfigTest.php new file mode 100644 index 0000000..5035bb0 --- /dev/null +++ b/tests/Feature/VpnGatewayConfigTest.php @@ -0,0 +1,162 @@ + '10.66.0.1', + 'VPN_HEALTH_PORT' => '8081', + 'VPN_CERT_DIR' => $dir.'/certs', + 'VPN_CONFIG_OUT' => $out, + 'VPN_RENDER_ONLY' => '1', + ], $env); + + $process = Process::fromShellCommandline( + 'bash '.escapeshellarg(base_path('docker/caddy/vpn-entrypoint.sh')) + ); + $process->setEnv($env); + $process->run(); + + $rendered = is_file($out) ? file_get_contents($out) : ''; + + exec('rm -rf '.escapeshellarg($dir)); + + expect($process->getExitCode())->toBe(0, $process->getErrorOutput()); + + return $rendered; +} + +it('bedient jeden Namen, für den ein Zertifikat da ist', function () { + $config = renderVpnConfig([ + 'VPN_INTERNAL_HOST' => 'admin.clupilot.test', + 'VPN_TUNNEL_HOSTS' => 'app.clupilot.test,www.clupilot.test', + ], ['admin.clupilot.test', 'app.clupilot.test', 'www.clupilot.test']); + + expect($config)->toContain('https://admin.clupilot.test:443') + ->and($config)->toContain('https://app.clupilot.test:443') + ->and($config)->toContain('https://www.clupilot.test:443'); +}); + +it('lässt einen Namen ohne Zertifikat aus, statt gar nicht zu starten', function () { + // Der ganze Grund für dieses Skript. Stünde www. mit einem `tls`-Pfad in + // der Konfiguration, den es nicht gibt, startete Caddy überhaupt nicht — + // und die Konsole wäre im Tunnel weg. + $config = renderVpnConfig([ + 'VPN_INTERNAL_HOST' => 'admin.clupilot.test', + 'VPN_TUNNEL_HOSTS' => 'app.clupilot.test,www.clupilot.test', + ], ['admin.clupilot.test', 'app.clupilot.test']); + + expect($config)->toContain('https://admin.clupilot.test:443') + ->and($config)->toContain('https://app.clupilot.test:443') + ->and($config)->not->toContain('www.clupilot.test'); +}); + +it('behält die Konsole, auch wenn sonst nichts ein Zertifikat hat', function () { + $config = renderVpnConfig([ + 'VPN_INTERNAL_HOST' => 'admin.clupilot.test', + 'VPN_TUNNEL_HOSTS' => 'app.clupilot.test,www.clupilot.test', + ], ['admin.clupilot.test']); + + expect($config)->toContain('https://admin.clupilot.test:443') + ->and($config)->not->toContain('app.clupilot.test') + ->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ß. + $config = renderVpnConfig([ + 'VPN_INTERNAL_HOST' => 'admin.clupilot.test', + 'VPN_TUNNEL_HOSTS' => '', + ], []); + + expect($config)->toContain('http://10.66.0.1:8081') + ->and($config)->toContain('respond /healthz 204'); +}); + +it('nimmt keinen Namen auf, den niemand konfiguriert hat', function () { + $config = renderVpnConfig([ + 'VPN_INTERNAL_HOST' => 'admin.clupilot.test', + 'VPN_TUNNEL_HOSTS' => '', + ], ['admin.clupilot.test', 'files.clupilot.test']); + + expect($config)->not->toContain('files.clupilot.test'); +}); + +it('schreibt die geladenen Zertifikate mit, damit die Erneuerung greift', function () { + // Der Update-Agent startet den Gateway neu, wenn sich eines der geladenen + // Zertifikate aendert — Caddys `tls` liest die Datei nur beim Start. Bisher + // ueberwachte er GENAU EINEN Pfad aus der .env; mit mehreren Namen liefe + // der Tunnel nach einer Erneuerung von www. mit einem abgelaufenen + // Zertifikat weiter. Die Liste ist, woran er sie erkennt. + $dir = sys_get_temp_dir().'/clupilot-vpn-'.bin2hex(random_bytes(6)); + mkdir($dir.'/certs/certificates/acme', 0755, true); + + foreach (['admin.clupilot.test', 'app.clupilot.test'] as $name) { + file_put_contents($dir."/certs/certificates/acme/{$name}.crt", 'cert'); + file_put_contents($dir."/certs/certificates/acme/{$name}.key", 'key'); + } + + $list = $dir.'/certs.list'; + + $process = Process::fromShellCommandline( + 'bash '.escapeshellarg(base_path('docker/caddy/vpn-entrypoint.sh')) + ); + $process->setEnv([ + 'VPN_INTERNAL_HOST' => 'admin.clupilot.test', + // www. hat kein Zertifikat und darf deshalb auch nicht in der Liste stehen. + 'VPN_TUNNEL_HOSTS' => 'app.clupilot.test,www.clupilot.test', + 'VPN_HUB_ADDRESS' => '10.66.0.1', + 'VPN_HEALTH_PORT' => '8081', + 'VPN_CERT_DIR' => $dir.'/certs', + 'VPN_CONFIG_OUT' => $dir.'/rendered.Caddyfile', + 'VPN_CERT_LIST' => $list, + 'VPN_RENDER_ONLY' => '1', + ]); + $process->run(); + + $written = is_file($list) ? file_get_contents($list) : ''; + + exec('rm -rf '.escapeshellarg($dir)); + + expect($process->getExitCode())->toBe(0, $process->getErrorOutput()) + ->and($written)->toContain('admin.clupilot.test.crt') + ->and($written)->toContain('app.clupilot.test.crt') + ->and($written)->not->toContain('www.clupilot.test'); +}); + +it('gibt jedem Block die Weiterleitung mit der echten Quelladresse', function () { + // Ohne X-Forwarded-For sähe die Anwendung den Gateway statt des Anrufers, + // und die Freigabeliste prüfte die falsche Adresse. + $config = renderVpnConfig([ + 'VPN_INTERNAL_HOST' => 'admin.clupilot.test', + 'VPN_TUNNEL_HOSTS' => 'app.clupilot.test', + ], ['admin.clupilot.test', 'app.clupilot.test']); + + expect(substr_count($config, 'header_up X-Forwarded-For {remote_host}'))->toBe(2); +});