diff --git a/app/Console/Commands/PublishTunnelNames.php b/app/Console/Commands/PublishTunnelNames.php new file mode 100644 index 0000000..5b0fbac --- /dev/null +++ b/app/Console/Commands/PublishTunnelNames.php @@ -0,0 +1,68 @@ +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; + } +} diff --git a/app/Services/Dns/FakeHostDnsDirectory.php b/app/Services/Dns/FakeHostDnsDirectory.php index 4bef50e..639476e 100644 --- a/app/Services/Dns/FakeHostDnsDirectory.php +++ b/app/Services/Dns/FakeHostDnsDirectory.php @@ -16,6 +16,9 @@ class FakeHostDnsDirectory implements HostDnsDirectory public bool $failRemove = false; + /** @var array> key => fqdns */ + public array $groups = []; + public function write(string $name, string $fqdn, string $ip): void { if ($this->failWrite) { @@ -34,4 +37,20 @@ class FakeHostDnsDirectory implements HostDnsDirectory unset($this->ips[$name], $this->fqdns[$name]); } + + 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; + } } diff --git a/app/Services/Dns/FileHostDnsDirectory.php b/app/Services/Dns/FileHostDnsDirectory.php index 3105df1..ec07463 100644 --- a/app/Services/Dns/FileHostDnsDirectory.php +++ b/app/Services/Dns/FileHostDnsDirectory.php @@ -42,6 +42,30 @@ class FileHostDnsDirectory implements HostDnsDirectory } } + 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); + } + private function ensureDir(): void { $dir = $this->dir(); diff --git a/app/Services/Dns/HostDnsDirectory.php b/app/Services/Dns/HostDnsDirectory.php index 53664d6..10a680d 100644 --- a/app/Services/Dns/HostDnsDirectory.php +++ b/app/Services/Dns/HostDnsDirectory.php @@ -14,4 +14,16 @@ interface HostDnsDirectory /** Removes the host's entry, if one exists. */ public function remove(string $name): void; + + /** + * 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; } diff --git a/tests/Feature/TunnelNamesTest.php b/tests/Feature/TunnelNamesTest.php new file mode 100644 index 0000000..c7fa38f --- /dev/null +++ b/tests/Feature/TunnelNamesTest.php @@ -0,0 +1,96 @@ +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');