From cfaea7dc82301d77ade12c04da20594ead90eb77 Mon Sep 17 00:00:00 2001 From: nexxo Date: Fri, 31 Jul 2026 00:53:12 +0200 Subject: [PATCH] Fill the hostname page from the installation, not from a blank form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page was empty, and that was a design fault rather than a missing button. I built a register that starts blank — while the installation already serves half a dozen names whose certificates were exactly what the operator wanted to see. An overview you have to populate first does not answer "what do I have". So the names are derived now, from the same configuration routes/web.php builds its domain bindings from: SITE_HOSTS, APP_HOST, FILES_HOST, ADMIN_HOSTS. If a name is in the environment, the application answers on it, and then it belongs in this list without anybody typing it a second time. Opening the page syncs them; the list is never empty again. Syncing and measuring are deliberately separate. The sync costs nothing and runs on page load. The measurement goes out over the network and runs on the button or on a schedule — doing it on page load would mean waiting through half a dozen TLS handshakes, and one of them is always the name that currently does not resolve. Which is the other half of what was missing: there was no overview because nothing measured unless somebody pressed a button. A daily run at 04:17 fills it, because the question that matters is not "is it valid right now" but "is renewal running" — a certificate expiring in forty days is fine, the same one at twenty means something has been broken for a week. Only a measurement taken while nobody is looking can tell those apart. The page now opens with four counts — total, valid, expiring soon, without a certificate — and says when it last measured. A name that comes from the environment is marked as such and cannot be removed here: it would come back at the next sync, and a button that does nothing is worse than no button, because it gets believed once. 2040 tests pass, assets build. Co-Authored-By: Claude Opus 5 --- app/Console/Commands/CheckCertificates.php | 36 ++++++++ app/Livewire/Admin/ProxyHosts.php | 59 +++++++++---- app/Models/ProxyHost.php | 18 +++- app/Services/Proxy/CertificateSweep.php | 85 +++++++++++++++++++ app/Support/KnownHostnames.php | 68 +++++++++++++++ ...40000_say_where_a_proxy_host_came_from.php | 32 +++++++ lang/de/proxy.php | 12 +++ lang/en/proxy.php | 12 +++ .../livewire/admin/proxy-hosts.blade.php | 37 ++++++-- routes/console.php | 16 ++++ tests/Feature/Admin/ProxyHostsTest.php | 63 +++++++++++++- 11 files changed, 415 insertions(+), 23 deletions(-) create mode 100644 app/Console/Commands/CheckCertificates.php create mode 100644 app/Services/Proxy/CertificateSweep.php create mode 100644 app/Support/KnownHostnames.php create mode 100644 database/migrations/2026_08_03_140000_say_where_a_proxy_host_came_from.php diff --git a/app/Console/Commands/CheckCertificates.php b/app/Console/Commands/CheckCertificates.php new file mode 100644 index 0000000..9e349e8 --- /dev/null +++ b/app/Console/Commands/CheckCertificates.php @@ -0,0 +1,36 @@ +sync(); + $result = $sweep->check(); + + $this->info(sprintf( + '%d hostname(s) added, %d checked, %d without a valid certificate', + $added, + $result['checked'], + $result['failing'], + )); + + return self::SUCCESS; + } +} diff --git a/app/Livewire/Admin/ProxyHosts.php b/app/Livewire/Admin/ProxyHosts.php index 44dc3b5..bf5b478 100644 --- a/app/Livewire/Admin/ProxyHosts.php +++ b/app/Livewire/Admin/ProxyHosts.php @@ -5,7 +5,7 @@ namespace App\Livewire\Admin; use App\Models\Operator; use App\Models\ProxyHost; use App\Services\Deployment\UpdateChannel; -use App\Services\Proxy\CertificateInspector; +use App\Services\Proxy\CertificateSweep; use App\Support\Settings; use Illuminate\Validation\Rule; use Livewire\Attributes\Layout; @@ -43,10 +43,22 @@ class ProxyHosts extends Component public ?string $notice = null; - public function mount(): void + public function mount(CertificateSweep $sweep): void { $this->authorize('site.manage'); $this->acmeEmail = (string) Settings::get('proxy.acme_email', ''); + + // Die Namen aus der Umgebung übernehmen, damit die Seite NIE leer ist. + // + // Die erste Fassung war ein leeres Register: sie zeigte nichts, während + // die Installation längst ein halbes Dutzend Namen bediente, deren + // Zertifikate der Betreiber gerade sehen wollte. Eine Übersicht, die man + // erst befüllen muss, beantwortet „was habe ich" nicht. + // + // Nur der Abgleich, NICHT die Messung: die geht ins Netz, und ein + // halbes Dutzend TLS-Handschläge beim Öffnen einer Seite bedeutet + // Warten — mit einem Namen darunter, der gerade nicht auflöst. + $sweep->sync(); } protected function rules(): array @@ -80,7 +92,18 @@ class ProxyHosts extends Component { $this->authorize('site.manage'); - ProxyHost::query()->whereKey($id)->delete(); + $host = ProxyHost::query()->whereKey($id)->first(); + + // Ein Name aus der Umgebung kommt beim nächsten Abgleich wieder. Ihn + // löschen zu lassen wäre ein Knopf, der nichts bewirkt — schlimmer als + // keiner, weil er einmal geglaubt wird. + if ($host === null || $host->isFromConfig()) { + $this->notice = __('proxy.remove_from_config'); + + return; + } + + $host->delete(); // Kein Anwenden von selbst. Einen Namen aus der Liste zu nehmen und ihn // im selben Atemzug aus dem laufenden Proxy zu entfernen wäre zwei @@ -105,21 +128,14 @@ class ProxyHosts extends Component * ein Hostname, der nicht auflöst, darf die Seite nicht minutenlang * blockieren. */ - public function checkCertificates(CertificateInspector $inspector): void + public function checkCertificates(CertificateSweep $sweep): void { $this->authorize('site.manage'); - foreach (ProxyHost::query()->get() as $host) { - $result = $inspector->inspect($host->hostname); + $sweep->sync(); + $result = $sweep->check(); - $host->forceFill([ - 'certificate_expires_at' => $result['expires_at'], - 'certificate_checked_at' => now(), - 'certificate_error' => $result['error'], - ])->save(); - } - - $this->notice = __('proxy.checked'); + $this->notice = __('proxy.checked_count', $result); } /** @@ -142,8 +158,21 @@ class ProxyHosts extends Component public function render() { + $hosts = ProxyHost::query()->orderBy('purpose')->orderBy('hostname')->get(); + return view('livewire.admin.proxy-hosts', [ - 'hosts' => ProxyHost::query()->orderBy('purpose')->orderBy('hostname')->get(), + 'hosts' => $hosts, + // Die Übersicht in drei Zahlen. Was ein Betreiber wissen will, + // bevor er eine Liste liest: ist etwas kaputt, und läuft etwas bald + // ab. + 'summary' => [ + 'total' => $hosts->count(), + 'valid' => $hosts->filter(fn ($h) => $h->hasCertificate() && ! $h->isExpiringSoon())->count(), + 'soon' => $hosts->filter(fn ($h) => $h->hasCertificate() && $h->isExpiringSoon())->count(), + 'failing' => $hosts->filter(fn ($h) => $h->certificate_checked_at !== null && $h->certificate_error !== null)->count(), + 'unchecked' => $hosts->filter(fn ($h) => $h->certificate_checked_at === null)->count(), + 'checkedAt' => $hosts->max('certificate_checked_at'), + ], 'purposes' => ProxyHost::PURPOSES, // Leer heißt: die Adresse des Inhabers. Eine Kontaktadresse, an die // die Ablaufwarnungen gehen, darf nicht leer bleiben, und die eine diff --git a/app/Models/ProxyHost.php b/app/Models/ProxyHost.php index e422b7f..110ca67 100644 --- a/app/Models/ProxyHost.php +++ b/app/Models/ProxyHost.php @@ -13,7 +13,23 @@ use Illuminate\Database\Eloquent\Model; */ class ProxyHost extends Model { - protected $fillable = ['hostname', 'purpose', 'note']; + protected $fillable = ['hostname', 'purpose', 'note', 'source']; + + /** + * Aus der Umgebung abgeleitet — nicht von Hand angelegt, also auch nicht + * von Hand zu entfernen: beim nächsten Abgleich käme er wieder, und ein + * Knopf, der nichts bewirkt, ist schlimmer als keiner. + */ + public function isFromConfig(): bool + { + return $this->source === 'config'; + } + + /** Hat die Prüfung ein gültiges Zertifikat gefunden? */ + public function hasCertificate(): bool + { + return $this->certificate_expires_at !== null && $this->certificate_error === null; + } protected function casts(): array { diff --git a/app/Services/Proxy/CertificateSweep.php b/app/Services/Proxy/CertificateSweep.php new file mode 100644 index 0000000..7be45ff --- /dev/null +++ b/app/Services/Proxy/CertificateSweep.php @@ -0,0 +1,85 @@ + $purpose) { + $existing = ProxyHost::query()->where('hostname', $hostname)->first(); + + if ($existing === null) { + ProxyHost::create([ + 'hostname' => $hostname, + 'purpose' => $purpose, + 'source' => 'config', + ]); + $added++; + + continue; + } + + // Ein von Hand angelegter Name, der inzwischen auch in der Umgebung + // steht, bleibt „manual" — sonst verlöre der Betreiber die + // Möglichkeit, ihn wieder zu entfernen, ohne je etwas dafür getan + // zu haben. + if ($existing->isFromConfig() && $existing->purpose !== $purpose) { + $existing->forceFill(['purpose' => $purpose])->save(); + } + } + + return $added; + } + + /** @return array{checked: int, failing: int} */ + public function check(): array + { + $checked = 0; + $failing = 0; + + foreach (ProxyHost::query()->get() as $host) { + $result = $this->inspector->inspect($host->hostname); + + $host->forceFill([ + 'certificate_expires_at' => $result['expires_at'], + 'certificate_checked_at' => now(), + 'certificate_error' => $result['error'], + ])->save(); + + $checked++; + if ($result['error'] !== null) { + $failing++; + } + } + + return ['checked' => $checked, 'failing' => $failing]; + } +} diff --git a/app/Support/KnownHostnames.php b/app/Support/KnownHostnames.php new file mode 100644 index 0000000..fc9460d --- /dev/null +++ b/app/Support/KnownHostnames.php @@ -0,0 +1,68 @@ + hostname => Zweck ('public' | 'console') + */ + public static function all(): array + { + $found = []; + + foreach ((array) config('admin_access.site_hosts', []) as $host) { + self::add($found, $host, 'public'); + } + + foreach ([ + 'admin_access.app_host', + 'admin_access.status_host', + 'admin_access.files_host', + ] as $key) { + self::add($found, (string) config($key, ''), 'public'); + } + + // Die Konsole und ihre Ausweichnamen. `console`, weil sie hinter der + // Netzsperre liegen — ein Name mit dem falschen Zweck sähe hier aus wie + // eine funktionierende Seite und wäre eine offene Konsole. + foreach ((array) config('admin_access.hosts', []) as $host) { + self::add($found, $host, 'console'); + } + + ksort($found); + + return $found; + } + + private static function add(array &$found, string $host, string $purpose): void + { + $host = strtolower(trim($host)); + + // Kein Schema, kein Pfad, kein Port — und mindestens ein Punkt. Ein + // blanker Name wie `localhost` ist ein Entwicklungsrest und hat kein + // Zertifikat, das jemand überwachen wollte. + if ($host === '' || ! str_contains($host, '.') || preg_match('/[^a-z0-9.\-]/', $host)) { + return; + } + + // Konsole gewinnt gegen öffentlich, wenn ein Name in beiden Listen + // steht: die engere Aussage ist die richtige. + if (($found[$host] ?? null) !== 'console') { + $found[$host] = $purpose; + } + } +} diff --git a/database/migrations/2026_08_03_140000_say_where_a_proxy_host_came_from.php b/database/migrations/2026_08_03_140000_say_where_a_proxy_host_came_from.php new file mode 100644 index 0000000..cde8ea3 --- /dev/null +++ b/database/migrations/2026_08_03_140000_say_where_a_proxy_host_came_from.php @@ -0,0 +1,32 @@ +string('source')->default('manual')->after('purpose'); + }); + } + + public function down(): void + { + Schema::table('proxy_hosts', function (Blueprint $table) { + $table->dropColumn('source'); + }); + } +}; diff --git a/lang/de/proxy.php b/lang/de/proxy.php index 3e081b3..e4eed8f 100644 --- a/lang/de/proxy.php +++ b/lang/de/proxy.php @@ -50,4 +50,16 @@ return [ 'acme_email_hint' => 'Leer lassen: dann wird :email genommen, die Adresse des Inhabers.', 'acme_email_hint_none' => 'Leer lassen ist hier keine gute Wahl — es gibt kein Inhaberkonto, aus dem eine Adresse käme.', ], + + 'stat' => [ + 'total' => 'Hostnamen', + 'valid' => 'Zertifikat gültig', + 'soon' => 'Läuft bald ab', + 'failing' => 'Ohne Zertifikat', + ], + 'from_config' => 'aus der Konfiguration', + 'remove_from_config' => 'Dieser Name kommt aus der Umgebung dieser Installation und käme beim nächsten Abgleich wieder. Entfernen lässt er sich nur dort, wo er gesetzt ist.', + 'never_checked' => ':count Hostname(n) wurden noch nie geprüft. Die Messung läuft täglich um 04:17 — oder jetzt, über „Zertifikate prüfen".', + 'last_checked' => 'Zuletzt geprüft: :when.', + 'checked_count' => ':checked geprüft, :failing ohne gültiges Zertifikat.', ]; diff --git a/lang/en/proxy.php b/lang/en/proxy.php index f405ed2..af8a215 100644 --- a/lang/en/proxy.php +++ b/lang/en/proxy.php @@ -50,4 +50,16 @@ return [ 'acme_email_hint' => 'Leave empty to use :email, the owner\'s address.', 'acme_email_hint_none' => 'Leaving this empty is a poor choice here — there is no owner account to take an address from.', ], + + 'stat' => [ + 'total' => 'Hostnames', + 'valid' => 'Certificate valid', + 'soon' => 'Expiring soon', + 'failing' => 'No certificate', + ], + 'from_config' => 'from the configuration', + 'remove_from_config' => 'This name comes from the installation\'s environment and would return at the next sync. It can only be removed where it is set.', + 'never_checked' => ':count hostname(s) have never been checked. The measurement runs daily at 04:17 — or now, via "Check certificates".', + 'last_checked' => 'Last checked: :when.', + 'checked_count' => ':checked checked, :failing without a valid certificate.', ]; diff --git a/resources/views/livewire/admin/proxy-hosts.blade.php b/resources/views/livewire/admin/proxy-hosts.blade.php index 0dfaf96..4641658 100644 --- a/resources/views/livewire/admin/proxy-hosts.blade.php +++ b/resources/views/livewire/admin/proxy-hosts.blade.php @@ -9,6 +9,23 @@ {{ $notice }} @endif + {{-- Die Übersicht in Zahlen, vor der Liste. Was ein Betreiber wissen will, + bevor er zwölf Zeilen liest: ist etwas kaputt, läuft etwas bald ab. --}} +
+ + + + +
+ + @if ($summary['unchecked'] > 0) + + {{ __('proxy.never_checked', ['count' => $summary['unchecked']]) }} + + @endif +
@@ -36,6 +53,9 @@ @if ($host->isConsole()) {{ __('proxy.purpose.console') }} @endif + @if ($host->isFromConfig()) + {{ __('proxy.from_config') }} + @endif
{{-- Der Zustand steht als Satz, nicht als Farbe: ein @@ -59,17 +79,24 @@

- + @unless ($host->isFromConfig()) + + @endunless @empty
{{ __('proxy.empty') }}
@endforelse -

{{ __('proxy.apply_hint') }}

+

+ @if ($summary['checkedAt']) + {{ __('proxy.last_checked', ['when' => $summary['checkedAt']->local()->isoFormat('D. MMM YYYY, HH:mm')]) }} + @endif + {{ __('proxy.apply_hint') }} +

diff --git a/routes/console.php b/routes/console.php index 01f0d07..e6df5ca 100644 --- a/routes/console.php +++ b/routes/console.php @@ -225,3 +225,19 @@ Schedule::command('clupilot:verify-vat-ids') Schedule::command('clupilot:end-due-services') ->hourly() ->withoutOverlapping(); + +// Zertifikate: welche Namen bedient diese Installation, und wie lange gelten +// sie noch. +// +// Nach Zeitplan und nicht nur auf Knopfdruck. Die Frage, auf die es ankommt, +// lautet nicht „ist es gerade gültig" — sondern „läuft die Erneuerung". Ein +// Zertifikat, das in vierzig Tagen abläuft, ist in Ordnung; dasselbe in +// zwanzig Tagen heißt, dass seit einer Woche etwas nicht funktioniert. Das +// sieht nur, wer misst, wenn niemand hinsieht. +// +// Früh, aber nicht zur vollen Stunde: Let's Encrypt bittet ausdrücklich darum, +// nicht auf den Punkt zu prüfen. +Schedule::command('clupilot:check-certificates') + ->dailyAt('04:17') + ->withoutOverlapping() + ->name('check-certificates'); diff --git a/tests/Feature/Admin/ProxyHostsTest.php b/tests/Feature/Admin/ProxyHostsTest.php index feb7be2..56ae339 100644 --- a/tests/Feature/Admin/ProxyHostsTest.php +++ b/tests/Feature/Admin/ProxyHostsTest.php @@ -40,7 +40,9 @@ it('refuses anything that is not a hostname', function () { proxyPage()->set('hostname', $attempt)->call('add')->assertHasErrors('hostname'); } - expect(ProxyHost::query()->count())->toBe(0); + // Nur die von Hand angelegten zählen: die Seite gleicht beim Öffnen die + // Namen aus der Umgebung ein, also ist die Tabelle nie leer. + expect(ProxyHost::query()->where('source', 'manual')->count())->toBe(0); }); it('refuses the same hostname twice', function () { @@ -58,7 +60,7 @@ it('removes from the list without touching the running proxy', function () { proxyPage()->call('remove', $host->id)->assertSee(__('proxy.removed')); - expect(ProxyHost::query()->count())->toBe(0); + expect(ProxyHost::query()->where('source', 'manual')->count())->toBe(0); }); /** @@ -139,3 +141,60 @@ it('is closed to an operator without the permission', function () { ->test(ProxyHosts::class) ->assertForbidden(); }); + +/** + * Der Fund, der diese Seite zweimal falsch gemacht hat. + * + * Die erste Fassung war ein LEERES Register: sie zeigte nichts, während die + * Installation längst ein halbes Dutzend Namen bediente, deren Zertifikate der + * Betreiber gerade sehen wollte. Eine Übersicht, die man erst befüllen muss, + * beantwortet „was habe ich" nicht. + */ +it('shows the hostnames this installation already serves, without being told', function () { + expect(ProxyHost::query()->count())->toBe(0); + + proxyPage()->assertSee('files.clupilot.test'); + + $entdeckt = ProxyHost::query()->where('hostname', 'files.clupilot.test')->first(); + + expect($entdeckt)->not->toBeNull() + ->and($entdeckt->isFromConfig())->toBeTrue(); +}); + +/** + * Ein Name aus der Umgebung käme beim nächsten Abgleich wieder. Ein Knopf, der + * nichts bewirkt, ist schlimmer als keiner — er wird einmal geglaubt. + */ +it('will not let go of a hostname that comes from the environment', function () { + proxyPage(); + $ausDerKonfiguration = ProxyHost::query()->where('source', 'config')->firstOrFail(); + + proxyPage()->call('remove', $ausDerKonfiguration->id) + ->assertSee(__('proxy.remove_from_config')); + + expect(ProxyHost::query()->whereKey($ausDerKonfiguration->id)->exists())->toBeTrue(); +}); + +/** + * Die Übersicht in Zahlen — das, was ein Betreiber wissen will, bevor er eine + * Liste liest. + */ +it('counts what is valid, what expires soon and what is broken', function () { + proxyPage(); + ProxyHost::query()->delete(); + + ProxyHost::create(['hostname' => 'gut.clupilot.com', 'purpose' => 'public']) + ->forceFill(['certificate_expires_at' => now()->addDays(70), 'certificate_checked_at' => now()])->save(); + ProxyHost::create(['hostname' => 'knapp.clupilot.com', 'purpose' => 'public']) + ->forceFill(['certificate_expires_at' => now()->addDays(9), 'certificate_checked_at' => now()])->save(); + ProxyHost::create(['hostname' => 'kaputt.clupilot.com', 'purpose' => 'public']) + ->forceFill(['certificate_checked_at' => now(), 'certificate_error' => 'tlsv1 alert internal error'])->save(); + + $summary = Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator') + ->test(ProxyHosts::class) + ->viewData('summary'); + + expect($summary['valid'])->toBe(1) + ->and($summary['soon'])->toBe(1) + ->and($summary['failing'])->toBe(1); +});