CluPilotCloud/app/Livewire/Admin/ProxyHosts.php

193 lines
7.0 KiB
PHP

<?php
namespace App\Livewire\Admin;
use App\Models\Operator;
use App\Models\ProxyHost;
use App\Services\Deployment\UpdateChannel;
use App\Services\Proxy\CertificateSweep;
use App\Support\Settings;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
/**
* Die Hostnamen dieser Installation — anlegen, ansehen, anwenden.
*
* Entstanden aus einem Handgriff, der teuer war: `files.clupilot.com` hatte
* einen DNS-Eintrag, hatte `FILES_HOST` in der Umgebung, lag auf der richtigen
* Maschine — und hatte trotzdem kein Zertifikat, weil `/etc/caddy/Caddyfile`
* von Hand gepflegt wird und niemand daran dachte, dass das ein zweiter,
* getrennter Schritt ist. Nichts in der Konsole hätte das verraten.
*
* Zwei Dinge deshalb an einer Stelle:
*
* - Der WUNSCH: welche Namen bedient werden sollen. Er geht über den Agenten
* in die Proxy-Konfiguration, ohne dass sich jemand auf den Server meldet.
* - Die WIRKLICHKEIT: ob der Name ein Zertifikat hat und wie lange noch.
* Gemessen an dem, was ein Besucher bekommt — nicht aus der Konfiguration
* gelesen, denn genau die sah im Fehlerfall richtig aus.
*/
#[Layout('layouts.admin')]
class ProxyHosts extends Component
{
#[Validate]
public string $hostname = '';
public string $purpose = 'public';
public string $note = '';
public string $acmeEmail = '';
public ?string $notice = null;
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
{
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');
}
}