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> */ 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 { // Surface a failed sentinel write instead of a "restarting" state that never resolves. if (! $deployment->requestRestart()) { $this->dispatch('notify', message: __('system.restart_write_failed'), level: 'error'); return; } $this->restartRequested = true; $this->dispatch('notify', message: __('system.restart_running')); } /** * Operator-triggered Let's-Encrypt request for the ACTIVE, Caddy-served domain: a DNS * pre-check + an on-demand-TLS handshake (DeploymentService::requestCertificate). Per-user * throttled (5 / 10 min, auto-expiring — never a control-plane lockout) so it can't hammer * ACME, and audited. Only meaningful in caddy mode with an active domain. */ public function requestCertificate(DeploymentService $deployment): void { $domain = $deployment->domain(); if ($domain === null || $deployment->externalTls()) { $this->dispatch('notify', message: __('system.cert_not_applicable'), level: 'error'); return; } $key = 'cert-request:'.Auth::id(); if (RateLimiter::tooManyAttempts($key, 5)) { $this->dispatch('notify', message: __('system.cert_throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error'); return; } RateLimiter::hit($key, 600); $result = $deployment->requestCertificate($domain); AuditEvent::create([ 'user_id' => Auth::id(), 'actor' => Auth::user()?->name ?? 'system', 'action' => 'tls.cert_request', 'target' => $domain.' → '.$result['status'], 'ip' => request()->ip(), ]); [$message, $level] = match ($result['status']) { 'issued' => [__('system.cert_issued', ['domain' => $domain]), 'success'], 'dns_mismatch' => [__('system.cert_dns_mismatch', [ 'resolved' => implode(', ', $result['resolved'] ?? []) ?: '—', 'server' => $result['serverIp'] ?? '?', ]), 'error'], default => [__('system.cert_failed'), 'error'], }; $this->dispatch('notify', message: $message, level: $level); } /** 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(), // Cert request is meaningful only when Caddy serves TLS for an ACTIVE domain. 'showCert' => $this->tlsMode === 'caddy' && $this->domain !== '', 'certStatus' => $deployment->certStatus(), ])->title(__('system.title')); } }