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 { return [ // Ein Hostname und nichts anderes. Der root-eigene Helfer prüft es // ein zweites Mal — das hier ist die Höflichkeit, jenes die Sperre. 'hostname' => [ 'required', 'string', 'max:253', 'regex:/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/', Rule::unique('proxy_hosts', 'hostname'), ], 'purpose' => ['required', Rule::in(ProxyHost::PURPOSES)], 'note' => ['nullable', 'string', 'max:255'], ]; } public function add(): void { $this->authorize('site.manage'); $data = $this->validate(); ProxyHost::create($data); $this->reset('hostname', 'note'); $this->purpose = 'public'; $this->notice = __('proxy.added'); } public function remove(int $id): void { $this->authorize('site.manage'); $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 // Entscheidungen in einem Klick — und die zweite nimmt eine Seite vom // Netz. $this->notice = __('proxy.removed'); } public function saveAcmeEmail(): void { $this->authorize('site.manage'); $this->validateOnly('acmeEmail', ['acmeEmail' => ['nullable', 'email', 'max:255']]); Settings::set('proxy.acme_email', trim($this->acmeEmail)); $this->notice = __('proxy.email_saved'); } /** * Was ein Besucher gerade wirklich bekommt. * * Nacheinander und mit kurzem Zeitablauf: die Liste ist zweistellig, und * ein Hostname, der nicht auflöst, darf die Seite nicht minutenlang * blockieren. */ public function checkCertificates(CertificateSweep $sweep): void { $this->authorize('site.manage'); $sweep->sync(); $result = $sweep->check(); $this->notice = __('proxy.checked_count', $result); } /** * Den Proxy die Liste übernehmen lassen — und dabei fehlende Zertifikate * anfordern. * * Ein eigener Knopf „erneuern" wäre daneben: Caddy erneuert von selbst bei * zwei Dritteln der Laufzeit. Was ein Betreiber wirklich braucht, ist ein * zweiter Versuch, nachdem eine Ausstellung gescheitert ist — und genau das * ist ein Neuladen. */ public function apply(UpdateChannel $channel): void { $this->authorize('site.manage'); $this->notice = $channel->requestProxyHosts((string) auth('operator')->id()) ? __('proxy.applying') : __('proxy.apply_failed'); } public function render() { $hosts = ProxyHost::query()->orderBy('purpose')->orderBy('hostname')->get(); return view('livewire.admin.proxy-hosts', [ '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 // Adresse, von der diese Installation sicher weiß, dass sie gelesen // wird, ist seine. 'fallbackEmail' => $this->ownerEmail(), ]); } private function ownerEmail(): ?string { return Operator::query() ->whereHas('roles', fn ($q) => $q->where('name', 'Owner')) ->orderBy('id') ->value('email'); } }