Umsetzungsplan: die oeffentlichen Seiten im Tunnel, Startskript vorher geprueft

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 <noreply@anthropic.com>
main
nexxo 2026-08-04 17:10:01 +02:00
parent afc2f90dac
commit 23027ac3c7
1 changed files with 908 additions and 0 deletions

View File

@ -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 `<key>.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
<?php
use App\Services\Dns\FileHostDnsDirectory;
use App\Services\Dns\HostDnsDirectory;
/**
* Die eigenen Namen im Tunnel-Resolver.
*
* Über eine hosts-Datei im schon vorhandenen dns-hosts-Volume, das dnsmasq mit
* --hostsdir überwacht — dieselbe Stelle, an der die Host-Namen landen. Kein
* zweites `--address` im Compose-Befehl: das fängt zusätzlich jede Unterdomain
* mit ab, und über eine variable Namensliste kann eine command-Zeile nicht
* schleifen.
*/
beforeEach(function () {
$this->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<int, string> $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<string, array<int, string>> 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
<?php
namespace App\Console\Commands;
use App\Services\Dns\HostDnsDirectory;
use Illuminate\Console\Command;
/**
* Macht Portal, Website und Statusseite im Management-Tunnel auflösbar.
*
* Ohne das trägt der Tunnel nur `10.66.0.0/24`, und diese Namen zeigen auf die
* öffentliche Adresse des Servers — der Verkehr geht also am Tunnel vorbei, und
* die Anwendung sieht die öffentliche Adresse des Anrufers statt seiner
* `10.66.0.x`. Genau daran scheiterte „über VPN die verborgene Seite ansehen":
* die Konsole ging, weil ihr Name als einziger umgebogen war.
*
* `files.` steht bewusst NICHT in der Liste. Dort holt ein Server im
* Rettungssystem sein Bootstrap-Archiv, und der ist per Definition nicht im
* Tunnel — bögen wir den Namen um, holte er es nie.
*
* Die Datei landet in demselben Verzeichnis, das dnsmasq mit `--hostsdir`
* überwacht, und in dem RegisterHostDns schon die Host-Namen ablegt. Kein
* Neustart, kein Reload: das Erscheinen der Datei IST die Veröffentlichung.
*/
class PublishTunnelNames extends Command
{
/** Eine Datei für die ganze Gruppe — siehe HostDnsDirectory::writeMany(). */
private const KEY = 'platform';
protected $signature = 'clupilot:publish-tunnel-names';
protected $description = 'Macht Portal, Website und Statusseite im Management-Tunnel auflösbar';
public function handle(HostDnsDirectory $dns): int
{
// Ohne Tunnel gibt es keinen Gateway, der diese Namen bedienen könnte.
// Eine hosts-Datei für einen Resolver, den niemand startet, ist nur
// etwas, das beim späteren Einschalten falsch sein kann.
if ((string) config('admin_access.vpn_internal_host') === '') {
$dns->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
<?php
use Symfony\Component\Process\Process;
/**
* Die Konfiguration des Tunnel-Gateways, wirklich ausgeführt.
*
* Nach dem Muster von HostStepTest: das Skript wird als Skript laufen gelassen,
* denn worauf es ankommt, ist was es AUSLÄSST — und ein Auslassen sieht man nur
* an der erzeugten Datei, nicht an der Absicht.
*
* Der Ausfall, um den es geht: Caddy startet nicht, wenn eine in `tls`
* genannte Datei fehlt. Ein noch nicht ausgestelltes Zertifikat für www. würde
* damit den Tunnel-Zugang zur KONSOLE mitnehmen — und das ist der Weg, auf dem
* sich ein ausgesperrter Betreiber zurückholt.
*/
function renderVpnConfig(array $env, array $certNames): string
{
$dir = sys_get_temp_dir().'/clupilot-vpn-'.bin2hex(random_bytes(6));
mkdir($dir.'/certs/certificates/acme', 0755, true);
foreach ($certNames as $name) {
file_put_contents($dir."/certs/certificates/acme/{$name}.crt", 'cert');
file_put_contents($dir."/certs/certificates/acme/{$name}.key", 'key');
}
$out = $dir.'/rendered.Caddyfile';
$env = array_merge([
'VPN_HUB_ADDRESS' => '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 215241)
- Modify: `deploy/update.sh` (VPN-Block ~Zeilen 335360)
- 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`.