clusev/app/Livewire/System/Index.php

261 lines
9.9 KiB
PHP

<?php
namespace App\Livewire\System;
use App\Models\AuditEvent;
use App\Models\Setting;
use App\Services\DeploymentService;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* System settings — dashboard-configurable Domain + TLS and the release channel.
*
* Domain & TLS: the panel domain is EDITABLE here. Saving persists it (Setting
* `panel_domain`, overriding the install-time APP_DOMAIN) and asks the operator to
* restart the stack; on restart everything re-derives from it (app.url, the Reverb
* client endpoint, Caddy's on-demand cert, the secure-cookie flag). Nothing switches
* mid-session — that is what makes it safe. Bare-IP/HTTP access stays a recovery path.
*
* Release-Kanal: a stable|beta choice persisted as a Setting (default from
* config('clusev.channel')), audited on change.
*/
#[Layout('layouts.app')]
class Index extends Component
{
/** Valid user-facing release channels (descriptions are localized at render time). */
public const CHANNELS = ['stable', 'beta'];
/** Valid TLS termination modes. */
public const TLS_MODES = ['caddy', 'external'];
/** Effective panel domain (DB override or install-time APP_DOMAIN); '' = bare IP. */
public string $domain = '';
/** The domain form field (FQDN, or empty to clear the override). */
public string $domainInput = '';
/** True after a save — shows the "restart the stack to apply" notice. */
public bool $restartPending = false;
/** True once the operator clicked "restart now" — the host watcher is applying it. */
public bool $restartRequested = false;
public string $channel = 'stable';
public string $tlsMode = 'caddy';
/** Channel key => localized description, built per-request for the view. */
private function channelDescriptions(): array
{
return [
'stable' => __('system.channel_stable'),
'beta' => __('system.channel_beta'),
];
}
public function mount(DeploymentService $deployment): void
{
// Active = what the stack currently serves (snapshot); the form edits the PENDING
// (configured) domain. They differ when a saved change awaits a restart.
$this->domain = (string) ($deployment->domain() ?? '');
$this->domainInput = (string) ($deployment->configuredDomain() ?? '');
$this->restartPending = $deployment->restartPending();
$this->restartRequested = $deployment->restartRequested();
$channel = Setting::get('release_channel', config('clusev.channel')) ?? 'stable';
// Clamp to a valid user channel — a stale/legacy value (e.g. 'dev') falls back to stable.
$this->channel = in_array($channel, self::CHANNELS, true) ? $channel : 'stable';
$this->tlsMode = $deployment->externalTls() ? 'external' : 'caddy';
}
/** @return array<string, array<int, string>> */
private function domainRules(): array
{
// Empty clears the override (revert to install-time APP_DOMAIN / bare IP).
return ['domainInput' => ['nullable', 'string', 'max:253', 'regex:'.DeploymentService::DOMAIN_REGEX]];
}
/** Validate, then confirm the domain change via the shared modal (audited there). */
public function confirmDomain(DeploymentService $deployment): void
{
$this->validate($this->domainRules(), [
'domainInput.regex' => __('system.domain_invalid'),
'domainInput.max' => __('system.domain_invalid'),
]);
$new = strtolower(trim($this->domainInput));
if ($new === (string) ($deployment->configuredDomain() ?? '')) {
return; // no change vs the already-configured (pending) domain
}
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('system.change_domain_heading'),
'body' => $new !== ''
? __('system.change_domain_body', ['domain' => $new])
: __('system.clear_domain_body'),
'confirmLabel' => __('system.change_domain_confirm'),
'danger' => true,
'icon' => 'globe',
'notify' => '', // defer: we surface the restart notice instead of a toast
'token' => ConfirmToken::issue(
'domainChanged',
['domain' => $new],
'system.settings_updated',
'panel_domain='.($new !== '' ? $new : '(cleared)'),
),
],
);
}
#[On('domainChanged')]
public function applyDomain(string $confirmToken, DeploymentService $deployment): void
{
try {
$payload = ConfirmToken::consume($confirmToken, 'domainChanged');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass attempt — no-op
}
$domain = strtolower(trim($payload['params']['domain']));
// Defensive re-validation (the value round-trips through the modal event).
if ($domain !== '' && ! preg_match(DeploymentService::DOMAIN_REGEX, $domain)) {
return;
}
$pending = $deployment->setDomain($domain);
// The active (served) domain stays frozen until restart; only the pending changes.
$this->domainInput = (string) ($pending ?? '');
$this->restartPending = $deployment->restartPending();
$this->dispatch('notify', message: __('system.domain_saved_notify'));
}
/**
* One-click restart: write the sentinel and let the HOST watcher restart the stack.
* The container never touches the Docker socket — it only requests via a marker file
* the scoped systemd .path unit reacts to (see docker/restart-sentinel/). Kept explicit
* (a button, not auto-fired on save) so the operator controls the timing.
*/
public function restartNow(DeploymentService $deployment): void
{
$deployment->requestRestart();
$this->restartRequested = true;
$this->dispatch('notify', message: __('system.restart_running'));
}
/** Release channel changes are audited (confirmation via the shared modal). */
public function confirmChannel(string $channel): void
{
if (! in_array($channel, self::CHANNELS, true) || $channel === $this->channel) {
return;
}
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('system.change_channel_heading'),
'body' => __('system.change_channel_body', ['channel' => $channel]),
'confirmLabel' => __('system.change_channel_confirm'),
'danger' => false,
'icon' => 'git-branch',
'notify' => __('system.change_channel_notify', ['channel' => $channel]),
'token' => ConfirmToken::issue(
'channelChanged',
['channel' => $channel],
'system.settings_updated',
'release_channel='.$channel,
),
],
);
}
#[On('channelChanged')]
public function applyChannel(string $confirmToken): void
{
try {
$payload = ConfirmToken::consume($confirmToken, 'channelChanged');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass attempt — no-op
}
$channel = $payload['params']['channel'];
if (! in_array($channel, self::CHANNELS, true)) {
return;
}
Setting::put('release_channel', $channel);
$this->channel = $channel;
}
/** TLS-mode changes are audited (confirmation via the shared modal). */
public function confirmTlsMode(string $mode): void
{
if (! in_array($mode, self::TLS_MODES, true) || $mode === $this->tlsMode) {
return;
}
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('system.change_tls_heading'),
'body' => $mode === 'external'
? __('system.change_tls_body_external')
: __('system.change_tls_body_caddy'),
'confirmLabel' => __('system.change_tls_confirm'),
'danger' => true,
'icon' => 'shield',
'notify' => '',
'token' => ConfirmToken::issue(
'tlsModeChanged',
['mode' => $mode],
),
],
);
}
#[On('tlsModeChanged')]
public function applyTlsMode(string $confirmToken, DeploymentService $deployment): void
{
try {
$payload = ConfirmToken::consume($confirmToken, 'tlsModeChanged');
} catch (InvalidConfirmToken) {
return; // forged / replayed / direct-bypass attempt — no-op
}
$mode = $payload['params']['mode'];
if (! in_array($mode, self::TLS_MODES, true)) {
return;
}
$deployment->setTlsMode($mode);
$this->tlsMode = $mode;
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => 'system.settings_updated',
'target' => 'tls_mode='.$mode,
'ip' => request()->ip(),
]);
$this->restartPending = true;
$this->dispatch('notify', message: __('system.tls_saved_notify'));
}
public function render(DeploymentService $deployment)
{
return view('livewire.system.index', [
'hasTls' => $this->domain !== '',
'panelUrl' => $deployment->panelUrl(),
'isOverridden' => $deployment->domainIsOverridden(),
'channels' => $this->channelDescriptions(),
])->title(__('system.title'));
}
}