164 lines
5.4 KiB
PHP
164 lines
5.4 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Admin;
|
|
|
|
use App\Models\Operator;
|
|
use App\Models\ProxyHost;
|
|
use App\Services\Deployment\UpdateChannel;
|
|
use App\Services\Proxy\CertificateInspector;
|
|
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(): void
|
|
{
|
|
$this->authorize('site.manage');
|
|
$this->acmeEmail = (string) Settings::get('proxy.acme_email', '');
|
|
}
|
|
|
|
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');
|
|
|
|
ProxyHost::query()->whereKey($id)->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(CertificateInspector $inspector): void
|
|
{
|
|
$this->authorize('site.manage');
|
|
|
|
foreach (ProxyHost::query()->get() as $host) {
|
|
$result = $inspector->inspect($host->hostname);
|
|
|
|
$host->forceFill([
|
|
'certificate_expires_at' => $result['expires_at'],
|
|
'certificate_checked_at' => now(),
|
|
'certificate_error' => $result['error'],
|
|
])->save();
|
|
}
|
|
|
|
$this->notice = __('proxy.checked');
|
|
}
|
|
|
|
/**
|
|
* 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()
|
|
{
|
|
return view('livewire.admin.proxy-hosts', [
|
|
'hosts' => ProxyHost::query()->orderBy('purpose')->orderBy('hostname')->get(),
|
|
'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');
|
|
}
|
|
}
|