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
parent
afc2f90dac
commit
23027ac3c7
|
|
@ -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 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`.
|
||||
Loading…
Reference in New Issue