193 lines
7.0 KiB
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');
|
|
}
|
|
}
|