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); +});