From 23027ac3c7f1cb599a52a0ca9e123eb5a0dc0f48 Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 17:10:01 +0200 Subject: [PATCH] Umsetzungsplan: die oeffentlichen Seiten im Tunnel, Startskript vorher geprueft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Das Startskript des Gateways ist im Plan keine Skizze: es lief. Mit einem Namen mit Zertifikat, einem ohne, einem mit Zertifikat aber ohne Schluessel und einem leeren Feld — die drei letzten werden ausgelassen und benannt, der Gesundheits-Port steht unabhaengig davon, Rueckgabewert 0. Dabei drei Stellen nachgezogen: eine if-Abfrage statt der AND-OR-Liste, an der install-agent.sh sich schon einmal selbst beendet hat, und "find -print -quit" mit "|| true" statt einer Pipe nach head, die SIGPIPE liefern kann. Co-Authored-By: Claude Opus 5 --- ...026-08-04-oeffentliche-seiten-im-tunnel.md | 908 ++++++++++++++++++ 1 file changed, 908 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-04-oeffentliche-seiten-im-tunnel.md diff --git a/docs/superpowers/plans/2026-08-04-oeffentliche-seiten-im-tunnel.md b/docs/superpowers/plans/2026-08-04-oeffentliche-seiten-im-tunnel.md new file mode 100644 index 0000000..2b148a4 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-oeffentliche-seiten-im-tunnel.md @@ -0,0 +1,908 @@ +# Öffentliche Seiten im Tunnel — Umsetzungsplan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Portal, Website und Statusseite sind im Management-Tunnel unter +denselben Namen erreichbar wie draußen — damit der Betreiber sie über VPN +ansehen kann, während sie für alle anderen verborgen bleiben. + +**Architecture:** Zwei Hälften. Der interne Resolver bekommt die Namen als +hosts-Datei im schon vorhandenen `dns-hosts`-Volume (kein root, keine +Compose-Änderung an `vpn-dns`). Der Tunnel-Gateway erzeugt seine +Caddy-Konfiguration beim Start aus den konfigurierten Namen und den +Zertifikaten, die tatsächlich vorliegen — Namen ohne Zertifikat werden +ausgelassen statt den Gateway zu verhindern. + +**Tech Stack:** Laravel 13.22, Pest 4.7, Bash, dnsmasq (`--hostsdir`), +Caddy 2 (alpine), Docker Compose. + +## Global Constraints + +- **Entwurf:** `docs/superpowers/specs/2026-08-04-oeffentliche-seiten-im-tunnel-design.md`. + Bei Abweichung gilt der Entwurf; bei Konflikt mit `CLAUDE.md` → STOP & fragen. +- **Testbefehl** — im Worktree, nie im Haupt-Checkout (dort arbeiten parallel + andere Sitzungen): + + ``` + docker exec -w /var/www/html/.claude/worktrees/eager-elion-c581f4 clupilot-app-1 php artisan test + ``` + + Der Worktree braucht dafür einmalig: `cp -al /home/nexxo/clupilot/vendor vendor`, + `cp /home/nexxo/clupilot/.env .env`, `mkdir -p bootstrap/cache + storage/framework/{cache/data,sessions,views} storage/logs storage/app/private`. + Ein *Symlink* auf `vendor/` funktioniert nicht — Composer leitet seine + Basispfade aus dem aufgelösten Verzeichnis ab und lädt dann die fremden Tests. +- **`files.` kommt NICHT in den Tunnel.** Ein Server im Rettungssystem holt dort + sein Archiv und ist per Definition nicht im Tunnel. +- **Der Konsolenname (`VPN_INTERNAL_HOST`) bleibt der erste und wichtigste.** + Keine Änderung darf dazu führen, dass er im Tunnel unerreichbar wird — das ist + der Weg, auf dem sich ein ausgesperrter Betreiber zurückholt. +- **`PublicSiteGate`, `RestrictConsoleNetwork` und die Freigabeliste werden + NICHT geändert.** Sobald der Verkehr durch den Tunnel kommt, ist die + Quelladresse `10.66.0.x`, und die steht bereits in `TRUSTED_RANGES`. +- Kommentare sagen WARUM, nicht WAS. Hausstil: `FileHostDnsDirectory`, + `vpn.Caddyfile`, `install-agent.sh`. +- Vor dem Release: `pwd`, `git branch --show-current` und den höchsten Tag + prüfen (`git tag -l 'v*' --sort=-v:refname | head -1`). Aktuell `v1.9.0`. + +--- + +## File Structure + +| Datei | Zuständigkeit | +|---|---| +| `app/Services/Dns/HostDnsDirectory.php` | **ändern** — Methode `writeMany()` | +| `app/Services/Dns/FileHostDnsDirectory.php` | **ändern** — schreibt eine Datei mit mehreren Zeilen | +| `app/Services/Dns/FakeHostDnsDirectory.php` | **ändern** — dieselbe Methode | +| `app/Console/Commands/PublishTunnelNames.php` | **neu** — `clupilot:publish-tunnel-names` | +| `tests/Feature/TunnelNamesTest.php` | **neu** | +| `docker/caddy/vpn-entrypoint.sh` | **neu** — rendert die Gateway-Konfiguration beim Start | +| `tests/Feature/VpnGatewayConfigTest.php` | **neu** — führt das Skript wirklich aus | +| `docker-compose.yml` | **ändern** — Gateway bekommt Namen und Entrypoint | +| `deploy/update.sh` | **ändern** — Profil hängt nicht mehr an den Zertifikatspfaden | +| `app/Console/Commands/BindHosts.php` | **ändern** — Hostnamen prüfen | +| `tests/Feature/BindHostsCommandTest.php` | **ändern** — zwei Tests dazu | +| `VERSION` | **ändern** — Release | + +--- + +## Task 1: Die Namen im Resolver + +**Files:** +- Modify: `app/Services/Dns/HostDnsDirectory.php` +- Modify: `app/Services/Dns/FileHostDnsDirectory.php` +- Modify: `app/Services/Dns/FakeHostDnsDirectory.php` +- Create: `app/Console/Commands/PublishTunnelNames.php` +- Create: `tests/Feature/TunnelNamesTest.php` + +**Interfaces:** +- Produces: `HostDnsDirectory::writeMany(string $key, array $fqdns, string $ip): void` + — schreibt **eine** Datei `.hosts` mit einer Zeile je FQDN; eine leere + Liste entfernt die Datei. Produces: Befehl `clupilot:publish-tunnel-names`. + +**Hintergrund:** `FileHostDnsDirectory` schreibt heute eine Datei je Host mit +genau einer Zeile. Für die Plattformnamen braucht es **eine** Datei mit +mehreren Zeilen — sonst müsste beim Wechsel von zwei auf einen Website-Namen +jemand die verwaiste Datei aufräumen, und genau das vergisst man. + +- [ ] **Step 1: Den Test schreiben** + +Datei `tests/Feature/TunnelNamesTest.php`: + +```php +dir = sys_get_temp_dir().'/clupilot-hosts-'.bin2hex(random_bytes(6)); + config()->set('provisioning.dns.hosts_dir', $this->dir); + config()->set('provisioning.wireguard.hub_address', '10.66.0.1'); + config()->set('admin_access.vpn_internal_host', 'admin.clupilot.test'); +}); + +afterEach(function () { + foreach (glob($this->dir.'/*') ?: [] as $file) { + @unlink($file); + } + @rmdir($this->dir); +}); + +it('veröffentlicht Portal, Website und Statusseite auf der Hub-Adresse', function () { + config()->set('admin_access.app_host', 'app.clupilot.test'); + config()->set('admin_access.site_hosts', ['www.clupilot.test', 'clupilot.test']); + config()->set('admin_access.status_host', 'status.clupilot.test'); + + $this->artisan('clupilot:publish-tunnel-names')->assertSuccessful(); + + $written = file_get_contents($this->dir.'/platform.hosts'); + + expect($written)->toContain('10.66.0.1 app.clupilot.test') + ->and($written)->toContain('10.66.0.1 www.clupilot.test') + ->and($written)->toContain('10.66.0.1 clupilot.test') + ->and($written)->toContain('10.66.0.1 status.clupilot.test'); +}); + +it('lässt den Dateihost draußen', function () { + // Ein Server im Rettungssystem holt dort sein Archiv — der ist per + // Definition nicht im Tunnel. Bögen wir den Namen um, holte er es nie. + config()->set('admin_access.app_host', 'app.clupilot.test'); + config()->set('admin_access.files_host', 'files.clupilot.test'); + + $this->artisan('clupilot:publish-tunnel-names')->assertSuccessful(); + + expect(file_get_contents($this->dir.'/platform.hosts')) + ->not->toContain('files.clupilot.test'); +}); + +it('räumt die Datei weg, wenn kein Name mehr konfiguriert ist', function () { + config()->set('admin_access.app_host', 'app.clupilot.test'); + $this->artisan('clupilot:publish-tunnel-names')->assertSuccessful(); + expect(file_exists($this->dir.'/platform.hosts'))->toBeTrue(); + + // Eine Datei, nicht eine je Name: sonst bliebe beim Wechsel von zwei + // Website-Namen auf einen die verwaiste Zeile im Resolver stehen, und der + // alte Name zeigte weiter in den Tunnel. + config()->set('admin_access.app_host', ''); + $this->artisan('clupilot:publish-tunnel-names')->assertSuccessful(); + + expect(file_exists($this->dir.'/platform.hosts'))->toBeFalse(); +}); + +it('tut nichts, solange kein Tunnel konfiguriert ist', function () { + // Ohne VPN_INTERNAL_HOST gibt es den Gateway nicht, und eine hosts-Datei + // für einen Resolver, den niemand startet, ist nur eine Datei, die beim + // nächsten Einschalten falsch sein kann. + config()->set('admin_access.vpn_internal_host', ''); + config()->set('admin_access.app_host', 'app.clupilot.test'); + + $this->artisan('clupilot:publish-tunnel-names')->assertSuccessful(); + + expect(file_exists($this->dir.'/platform.hosts'))->toBeFalse(); +}); + +it('schreibt genau eine Zeile je Name, im hosts-Format', function () { + config()->set('admin_access.app_host', 'app.clupilot.test'); + config()->set('admin_access.site_hosts', ['www.clupilot.test']); + + app(HostDnsDirectory::class)->writeMany('probe', ['a.test', 'b.test'], '10.66.0.1'); + + expect(file_get_contents($this->dir.'/probe.hosts')) + ->toBe("10.66.0.1 a.test\n10.66.0.1 b.test\n"); +}); + +it('ist dieselbe Datei-Umsetzung wie für die Host-Namen', function () { + // Nicht Fake gegen Fake geprüft: die Rechte und das Anlegen des + // Verzeichnisses sind der Teil, der in Produktion schiefgeht. + expect(app(HostDnsDirectory::class))->toBeInstanceOf(FileHostDnsDirectory::class); +})->skip(fn () => ! app()->environment('testing'), 'nur im Testlauf'); +``` + +- [ ] **Step 2: Den Test laufen lassen und den Fehlschlag ansehen** + +Run: `docker exec -w /var/www/html/.claude/worktrees/eager-elion-c581f4 clupilot-app-1 php artisan test --filter=TunnelNames` +Expected: FAIL — „The command 'clupilot:publish-tunnel-names' does not exist." + +- [ ] **Step 3: `writeMany` an die Schnittstelle** + +In `app/Services/Dns/HostDnsDirectory.php`, hinter `write()`: + +```php + /** + * Schreibt EINE Datei mit einer Zeile je FQDN. Eine leere Liste entfernt sie. + * + * Eine Datei statt einer je Name, weil diese Gruppe sich ändert: fällt ein + * Website-Name weg, verschwindet er dadurch mit — bei einer Datei je Name + * bliebe die verwaiste Zeile stehen und der alte Name zeigte weiter in den + * Tunnel. + * + * @param array $fqdns + */ + public function writeMany(string $key, array $fqdns, string $ip): void; +``` + +In `app/Services/Dns/FileHostDnsDirectory.php`: + +```php + public function writeMany(string $key, array $fqdns, string $ip): void + { + if ($fqdns === []) { + $this->remove($key); + + return; + } + + $this->ensureDir(); + + $path = $this->path($key); + $body = ''; + + foreach ($fqdns as $fqdn) { + $body .= "{$ip} {$fqdn}\n"; + } + + if (@file_put_contents($path, $body) === false) { + throw new RuntimeException("Could not write DNS hosts entry: {$path}"); + } + + @chmod($path, 0664); + } +``` + +In `app/Services/Dns/FakeHostDnsDirectory.php`: + +```php + /** @var array> key => fqdns */ + public array $groups = []; + + public function writeMany(string $key, array $fqdns, string $ip): void + { + if ($this->failWrite) { + throw new RuntimeException('dns-hosts volume unavailable'); + } + + if ($fqdns === []) { + unset($this->groups[$key], $this->ips[$key]); + + return; + } + + $this->groups[$key] = array_values($fqdns); + $this->ips[$key] = $ip; + } +``` + +- [ ] **Step 4: Den Befehl schreiben** + +Datei `app/Console/Commands/PublishTunnelNames.php`: + +```php +writeMany(self::KEY, [], ''); + $this->info('Kein Tunnel konfiguriert — nichts veröffentlicht.'); + + return self::SUCCESS; + } + + $names = array_values(array_unique(array_filter(array_merge( + [(string) config('admin_access.app_host')], + array_map('strval', (array) config('admin_access.site_hosts', [])), + [(string) config('admin_access.status_host')], + )))); + + $hub = (string) config('provisioning.wireguard.hub_address'); + + $dns->writeMany(self::KEY, $names, $hub); + + if ($names === []) { + $this->info('Keine Hostnamen konfiguriert — nichts veröffentlicht.'); + + return self::SUCCESS; + } + + foreach ($names as $name) { + $this->line(" {$hub} {$name}"); + } + + return self::SUCCESS; + } +} +``` + +- [ ] **Step 5: Tests laufen lassen** + +Run: `docker exec -w /var/www/html/.claude/worktrees/eager-elion-c581f4 clupilot-app-1 php artisan test --filter=TunnelNames` +Expected: PASS. + +- [ ] **Step 6: Voller Testlauf und Commit** + +Run: `docker exec -w /var/www/html/.claude/worktrees/eager-elion-c581f4 clupilot-app-1 php artisan test` +Expected: alles grün. Ein Filterlauf genügt nicht — `HostDnsDirectory` wird von +der Provisionierung mitbenutzt. + +```bash +git add app/Services/Dns tests/Feature/TunnelNamesTest.php app/Console/Commands/PublishTunnelNames.php +git commit -m "Die eigenen Namen loesen jetzt auch im Tunnel auf" +``` + +--- + +## Task 2: Der Gateway erzeugt seine Konfiguration selbst + +**Files:** +- Create: `docker/caddy/vpn-entrypoint.sh` +- Create: `tests/Feature/VpnGatewayConfigTest.php` + +**Interfaces:** +- Consumes: nichts aus Task 1. +- Produces: `docker/caddy/vpn-entrypoint.sh` — liest `VPN_INTERNAL_HOST`, + `VPN_TUNNEL_HOSTS` (kommagetrennt), `VPN_HUB_ADDRESS`, `VPN_HEALTH_PORT`, + `VPN_CERT_DIR` (Vorgabe `/certs`), `VPN_CONFIG_OUT` (Vorgabe + `/tmp/vpn.Caddyfile`); rendert die Konfiguration und startet Caddy per `exec`. + +**Hintergrund:** Heute mountet Compose eine feste `vpn.Caddyfile` mit **einem** +Site-Block und reicht **ein** Zertifikatspaar über `VPN_CERT_PATH`/`VPN_KEY_PATH` +hinein. Mehrere Namen brauchen mehrere Blöcke mit je eigenem Zertifikat — und +Caddy startet nicht, wenn eine der genannten Dateien fehlt. Ein noch nicht +ausgestelltes Zertifikat für `www.` nähme so den Tunnel-Zugang zur Konsole mit. + +- [ ] **Step 1: Den Test schreiben** + +Datei `tests/Feature/VpnGatewayConfigTest.php`: + +```php + '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('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); +}); +``` + +- [ ] **Step 2: Den Test laufen lassen und den Fehlschlag ansehen** + +Run: `docker exec -w /var/www/html/.claude/worktrees/eager-elion-c581f4 clupilot-app-1 php artisan test --filter=VpnGatewayConfig` +Expected: FAIL — das Skript gibt es nicht. + +- [ ] **Step 3: Das Startskript schreiben** + +Datei `docker/caddy/vpn-entrypoint.sh`: + +```bash +#!/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}" + +{ + 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" +} + +# `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 +``` + +Ausführbar machen: `chmod +x docker/caddy/vpn-entrypoint.sh` + +- [ ] **Step 4: Tests laufen lassen** + +Run: `docker exec -w /var/www/html/.claude/worktrees/eager-elion-c581f4 clupilot-app-1 php artisan test --filter=VpnGatewayConfig` +Expected: PASS, sechs Tests. + +- [ ] **Step 5: Voller Testlauf und Commit** + +```bash +git add docker/caddy/vpn-entrypoint.sh tests/Feature/VpnGatewayConfigTest.php +git commit -m "Der Tunnel-Gateway laesst Namen ohne Zertifikat aus, statt nicht zu starten" +``` + +--- + +## Task 3: Compose und update.sh verdrahten + +**Files:** +- Modify: `docker-compose.yml` (Block `vpn-gateway`, ~Zeilen 215–241) +- Modify: `deploy/update.sh` (VPN-Block ~Zeilen 335–360) +- Delete: `docker/caddy/vpn.Caddyfile` + +**Interfaces:** +- Consumes: `docker/caddy/vpn-entrypoint.sh` aus Task 2. + +- [ ] **Step 1: Den Gateway-Block umstellen** + +In `docker-compose.yml`, im `vpn-gateway`-Dienst: die Zeile, die +`./docker/caddy/vpn.Caddyfile` einhängt, ersetzen durch das Startskript, und die +Umgebung ergänzen. `VPN_CERT_PATH`/`VPN_KEY_PATH` bleiben stehen — sie werden +nur nicht mehr gelesen (siehe Entwurf, Abschnitt 3). + +```yaml + volumes: + # 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 + - ${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:-} + VPN_HUB_ADDRESS: ${CLUPILOT_WG_HUB_ADDRESS:-10.66.0.1} + VPN_HEALTH_PORT: ${VPN_HEALTH_PORT:-8081} +``` + +Hinweis: `SITE_HOST` ist selbst schon kommagetrennt — das Skript trennt an +Kommata und überspringt leere Felder, beides ist damit abgedeckt. + +- [ ] **Step 2: `vpn.Caddyfile` entfernen** + +```bash +git rm docker/caddy/vpn.Caddyfile +``` + +Die Begründungen aus ihrem Kopfkommentar stehen jetzt im Startskript; nichts +davon geht verloren. + +- [ ] **Step 3: `update.sh` — das Profil hängt nicht mehr an den Pfaden** + +Im VPN-Block von `deploy/update.sh` steht heute sinngemäß: sind +`VPN_CERT_PATH`/`VPN_KEY_PATH` leer, wird das Profil abgeschaltet („Both or +neither: leaving the profile on with empty tls paths starts a Caddy that cannot +load its configuration and crashes forever"). + +Das gilt nicht mehr — der Gateway sucht selbst und lässt aus, was fehlt. +Ersetze die Bedingung, die das Profil bei leeren Pfaden abschaltet, durch: + +```bash + # 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. +``` + +und lass das Profil eingeschaltet, solange `VPN_INTERNAL_HOST` gesetzt ist. Die +Prüfung „gehört das Zertifikat zum aktuellen Hostnamen" darf bleiben; sie +schadet nicht und räumt einen veralteten Pfad weg. + +- [ ] **Step 4: Syntax und voller Testlauf** + +Run: `bash -n deploy/update.sh` +Run: `docker compose config --quiet` (im Worktree; prüft die YAML-Struktur) +Run: `docker exec -w /var/www/html/.claude/worktrees/eager-elion-c581f4 clupilot-app-1 php artisan test` + +- [ ] **Step 5: Commit** + +```bash +git add docker-compose.yml deploy/update.sh +git rm --cached docker/caddy/vpn.Caddyfile 2>/dev/null || true +git commit -m "Der Gateway bekommt seine Namen aus der .env und sucht die Zertifikate selbst" +``` + +--- + +## Task 4: `bind-hosts` lehnt ab, was kein Hostname sein kann + +**Files:** +- Modify: `app/Console/Commands/BindHosts.php` +- Modify: `tests/Feature/BindHostsCommandTest.php` + +**Hintergrund:** In die `.env` der Produktivmaschine gelangte +`SITE_HOST=[www.clupilot.com](https://www.clupilot.com)` — ein Markdown-Link aus +einem Kopiervorgang. `EnvFileEditor` prüft nur `KEY=value`, also wurde er +geschrieben, und die Website war an einen Namen gebunden, den keine Anfrage je +trifft. + +- [ ] **Step 1: Die Tests schreiben** + +An `tests/Feature/BindHostsCommandTest.php` anhängen: + +```php +it('lehnt ab, was kein Hostname sein kann, und schreibt nichts', function () { + file_put_contents($this->envPath, "APP_URL=https://app.example.test\n"); + + // Genau der Wert, der auf der Produktivmaschine landete. Er ist als + // Hostname offensichtlich Unsinn und wurde trotzdem geschrieben, weil + // EnvFileEditor nur prueft, ob die ZEILE die Form KEY=value hat. + $this->artisan('clupilot:bind-hosts', [ + '--site' => '[www.example.test](https://www.example.test)', + '--force' => true, + ])->assertFailed(); + + expect(file_get_contents($this->envPath))->not->toContain('SITE_HOST') + ->and(glob($this->envPath.'.bak-*'))->toBeEmpty(); +}); + +it('prüft jeden Namen einer Komma-Liste einzeln', function () { + file_put_contents($this->envPath, "APP_URL=https://app.example.test\n"); + + // Der erste Name ist gültig — geschrieben werden darf trotzdem nichts, + // sonst stünde die halbe Liste in der Datei. + $this->artisan('clupilot:bind-hosts', [ + '--site' => 'www.example.test,nicht gültig', + '--force' => true, + ])->assertFailed(); + + expect(file_get_contents($this->envPath))->not->toContain('SITE_HOST'); +}); +``` + +- [ ] **Step 2: Laufen lassen, Fehlschlag ansehen** + +Run: `docker exec -w /var/www/html/.claude/worktrees/eager-elion-c581f4 clupilot-app-1 php artisan test --filter=BindHostsCommand` +Expected: die beiden neuen Tests FAIL — der Befehl schreibt heute beides. + +- [ ] **Step 3: Die Prüfung einbauen** + +In `app/Console/Commands/BindHosts.php`, direkt nachdem `$wanted` gebildet und +bevor `$missing` gefüllt wird: + +```php + foreach ($wanted as $key => $value) { + if ($value !== '' && ! $this->isHostList($value)) { + $this->error("{$key}: „{$value}" ist kein Hostname."); + $this->line('Erwartet wird ein Name wie app.example.com, mehrere kommagetrennt.'); + + return self::FAILURE; + } + } +``` + +und als private Methoden: + +```php + /** Eine Komma-Liste, in der JEDER Eintrag ein Hostname ist. */ + private function isHostList(string $value): bool + { + $names = array_map('trim', explode(',', $value)); + + if ($names === [] || in_array('', $names, true)) { + return false; + } + + foreach ($names as $name) { + if (! $this->isHost($name)) { + return false; + } + } + + return true; + } + + /** + * Dasselbe Muster, das der root-eigene Helfer in `apply-proxy-hosts` + * benutzt, bevor er einen Namen in die Proxy-Konfiguration schreibt + * (deploy/install-agent.sh). + * + * Der Anlass ist konkret: `[www.example.com](https://www.example.com)` ist + * als Hostname offensichtlich Unsinn und wurde trotzdem geschrieben, weil + * die Zeile die Form KEY=value hatte. Das Repo kennt diese Falle schon — + * RestrictConsoleNetwork::isNetwork() gibt es, weil ein Eintrag, der nichts + * trifft, sonst „stored happily and reports success". + */ + private function isHost(string $name): bool + { + return (bool) preg_match( + '/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/', + $name, + ); + } +``` + +- [ ] **Step 4: Tests und Commit** + +Run: `docker exec -w /var/www/html/.claude/worktrees/eager-elion-c581f4 clupilot-app-1 php artisan test` + +```bash +git add app/Console/Commands/BindHosts.php tests/Feature/BindHostsCommandTest.php +git commit -m "bind-hosts nimmt keinen Wert mehr an, der kein Hostname sein kann" +``` + +--- + +## Task 5: Voller Testlauf und Release + +- [ ] **Step 1: Voller Testlauf** + +Run: `docker exec -w /var/www/html/.claude/worktrees/eager-elion-c581f4 clupilot-app-1 php artisan test` +Expected: alles grün. Fehlschläge außerhalb der neuen Dateien: **STOP** und melden. + +- [ ] **Step 2: Den höchsten Tag prüfen — VOR dem Versionssprung** + +```bash +git fetch --tags && git tag -l 'v*' --sort=-v:refname | head -3 && pwd && git branch --show-current +``` + +Es laufen mehrere Sitzungen an diesem Repo. Aktuell steht `v1.9.0`; liegt der +höchste Tag höher, wird entsprechend höher gewählt. + +- [ ] **Step 3: VERSION setzen und commiten** + +`VERSION` auf `1.10.0`. + +```bash +git add VERSION +git commit -m "Version 1.10.0 — die oeffentlichen Seiten sind im Tunnel erreichbar" +``` + +- [ ] **Step 4: Nach main, taggen, pushen** + +`git switch main` scheitert in diesem Worktree — `main` ist im Haupt-Checkout +ausgecheckt. Vorher sicherstellen, dass er sauber ist +(`git -C /home/nexxo/clupilot status --porcelain` leer), dann: + +```bash +git -C /home/nexxo/clupilot merge --ff-only claude/eager-elion-c581f4 +``` + +```bash +git -C /home/nexxo/clupilot tag -a v1.10.0 -m "Die oeffentlichen Seiten sind im Tunnel erreichbar" +``` + +```bash +git -C /home/nexxo/clupilot push origin main && git -C /home/nexxo/clupilot push origin v1.10.0 +``` + +- [ ] **Step 5: Dem Betreiber sagen, was auf dem Server zu tun ist** + +Zusammenfassen, nicht ausführen: + +1. `docker compose exec -T -u www-data app php artisan clupilot:publish-tunnel-names` +2. `docker compose --profile vpn up -d vpn-gateway` (das Startskript ist neu) +3. Am Telefon über VPN prüfen: `app.`, `www.` und `status.` zeigen die echte + Seite. + +--- + +## Self-Review + +**Spec-Abdeckung** + +| Entwurfsabschnitt | Task | +|---|---| +| 1. Resolver — hosts-Datei | Task 1 | +| 2. Gateway rendert selbst, lässt aus | Task 2 | +| 3. `VPN_CERT_PATH` bleibt stehen, `update.sh` | Task 3, Step 3 | +| 4. `bind-hosts` härten | Task 4 | +| Abnahme 1 (`app.`, `www.`, `status.` über VPN) | Task 1 + 2, End-zu-End in Task 5 Step 5 | +| Abnahme 2 (ohne VPN Platzhalter) | unverändert — `PublicSiteGate` nicht angefasst | +| Abnahme 3 (Name ohne Zertifikat) | Task 2, Tests 2 und 3 | +| Abnahme 4 (`bind-hosts` lehnt ab) | Task 4 | +| Abnahme 5 (`files.` bleibt draußen) | Task 1, Test 2 + Task 2, Test 5 | +| Abnahme 6 (voller Lauf) | Task 5 | + +**Platzhalter:** keine. Jeder Codeblock ist vollständig. + +**Typkonsistenz:** `HostDnsDirectory::writeMany(string $key, array $fqdns, +string $ip): void` — in Task 1 definiert, in `FileHostDnsDirectory`, +`FakeHostDnsDirectory` und `PublishTunnelNames` gleichlautend verwendet. Das +Startskript liest `VPN_TUNNEL_HOSTS`; Task 3 setzt genau diesen Namen in +`docker-compose.yml`.