can('manage-panel'), 403); $this->emailTo = (string) Setting::get('alert_email_to', ''); $this->webhooks = (string) Setting::get('alert_webhooks', ''); $this->gotifyUrl = (string) Setting::get('alert_gotify_url', ''); $this->gotifyTokenSet = ((string) Setting::get('alert_gotify_token', '')) !== ''; } public function title(): string { return __('alerts.title'); } private function gate(): void { abort_unless(Auth::user()?->can('manage-panel'), 403); } private function audit(string $action, string $target): void { AuditEvent::create([ 'user_id' => Auth::id(), 'actor' => Auth::user()?->name ?? 'system', 'action' => $action, 'target' => $target, 'ip' => request()->ip(), ]); } public function createRule(): void { $this->gate(); $data = $this->validate([ 'name' => ['required', 'string', 'max:80'], 'metric' => ['required', Rule::in(AlertRule::METRICS)], 'comparator' => ['required', Rule::in(AlertRule::COMPARATORS)], 'threshold' => ['required', 'numeric', 'min:0', 'max:10000'], // numeric: `load` can be fractional 'scopeType' => ['required', Rule::in(AlertRule::SCOPES)], ]); // Resolve the scope target (a group/server uuid) to its id — never trust a client-sent id. $scopeId = null; if ($data['scopeType'] === 'group') { $scopeId = ServerGroup::where('uuid', $this->scopeUuid)->firstOrFail()->id; } elseif ($data['scopeType'] === 'server') { $scopeId = Server::where('uuid', $this->scopeUuid)->firstOrFail()->id; } $rule = AlertRule::create([ 'name' => $data['name'], 'metric' => $data['metric'], 'comparator' => $data['comparator'], 'threshold' => $data['metric'] === 'offline' ? 0 : $data['threshold'], 'scope_type' => $data['scopeType'], 'scope_id' => $scopeId, 'enabled' => true, ]); $this->audit('alert.rule_create', $rule->name); $this->reset('name', 'scopeUuid'); $this->dispatch('notify', message: __('alerts.rule_created', ['name' => $rule->name])); } public function toggleRule(string $uuid): void { $this->gate(); $rule = AlertRule::where('uuid', $uuid)->firstOrFail(); $rule->update(['enabled' => ! $rule->enabled]); $this->audit('alert.rule_update', $rule->name); } public function confirmDeleteRule(string $uuid): void { $this->gate(); $rule = AlertRule::where('uuid', $uuid)->firstOrFail(); $this->dispatch('openModal', component: 'modals.confirm-action', arguments: [ 'heading' => __('alerts.delete_heading'), 'body' => __('alerts.delete_body', ['name' => $rule->name]), 'confirmLabel' => __('common.delete'), 'danger' => true, 'icon' => 'trash', 'notify' => __('alerts.rule_deleted', ['name' => $rule->name]), 'token' => ConfirmToken::issue('alertRuleDeleted', ['uuid' => $rule->uuid], 'alert.rule_delete', $rule->name, null), ], ); } #[On('alertRuleDeleted')] public function deleteRule(string $confirmToken): void { $this->gate(); try { $payload = ConfirmToken::consume($confirmToken, 'alertRuleDeleted'); } catch (InvalidConfirmToken) { return; } // NOT audited here — the confirm modal wrote alert.rule_delete from the sealed token. AlertRule::where('uuid', $payload['params']['uuid'])->first()?->delete(); // incidents cascade } public function saveChannels(): void { $this->gate(); // SSRF guard: refuse to store a webhook whose scheme isn't http(s) or whose host resolves to // an internal/loopback/private address. Checked here (immediate feedback) and again at send. foreach (preg_split('/[\r\n]+/', trim($this->webhooks)) ?: [] as $line) { $line = trim($line); if ($line !== '' && ! AlertNotifier::isSafeWebhookUrl($line)) { $this->addError('webhooks', __('alerts.webhook_invalid', ['url' => $line])); return; } } // Gotify: scheme-checked (a self-hosted server on a LAN is allowed — no private-range block). $gotifyUrl = trim($this->gotifyUrl); if ($gotifyUrl !== '' && ! AlertNotifier::isValidGotifyUrl($gotifyUrl)) { $this->addError('gotifyUrl', __('alerts.gotify_invalid')); return; } $newToken = trim($this->gotifyToken); $urlChanged = $gotifyUrl !== (string) Setting::get('alert_gotify_url', ''); // If the URL changes, DROP the old token BEFORE the new URL is stored — so no concurrent // send can ever pair the old token with the new (possibly attacker-controlled) endpoint. A // fresh token (if entered) is written afterwards; otherwise re-entry is required. if ($urlChanged) { Setting::forget('alert_gotify_token'); $this->gotifyTokenSet = false; } Setting::put('alert_email_to', trim($this->emailTo)); Setting::put('alert_webhooks', trim($this->webhooks)); Setting::put('alert_gotify_url', $gotifyUrl); // Encrypt a NEWLY entered token; an empty field on an UNCHANGED url leaves the stored one. if ($newToken !== '') { Setting::put('alert_gotify_token', Crypt::encryptString($newToken)); $this->gotifyToken = ''; $this->gotifyTokenSet = true; } $this->audit('alert.channels_update', __('alerts.channels')); $this->dispatch('notify', message: __('alerts.channels_saved')); } /** Remove the stored Gotify app token (write-only credential). */ public function clearGotifyToken(): void { $this->gate(); Setting::forget('alert_gotify_token'); $this->gotifyToken = ''; $this->gotifyTokenSet = false; $this->audit('alert.channels_update', __('alerts.channels')); $this->dispatch('notify', message: __('alerts.channels_saved')); } /** True when at least one delivery channel could actually reach someone (drives the warning). * Reads the SAVED config with the SAME validation the sender uses, so the banner never hides * for a channel that would be silently skipped at send time. */ private function channelsReach(): bool { if (MailSettings::isConfigured()) { return true; } $webhooks = array_filter( array_map('trim', preg_split('/[\r\n,]+/', (string) Setting::get('alert_webhooks', '')) ?: []), fn (string $u) => $u !== '' && AlertNotifier::isSafeWebhookUrl($u), ); if ($webhooks !== []) { return true; } return AlertNotifier::gotifyTarget() !== null; } public function render(): View { return view('livewire.alerts.index', [ 'channelsReach' => $this->channelsReach(), 'rules' => AlertRule::orderBy('name')->get(), 'incidents' => AlertIncident::with(['rule', 'server'])->where('state', 'firing')->latest('started_at')->limit(50)->get(), 'groups' => ServerGroup::orderBy('name')->get(['uuid', 'name']), 'servers' => Server::orderBy('name')->get(['uuid', 'name']), // id → name maps so a rule's scope target renders without a per-rule query (no N+1). 'groupNames' => ServerGroup::pluck('name', 'id'), 'serverNames' => Server::pluck('name', 'id'), 'metrics' => AlertRule::METRICS, 'comparators' => AlertRule::COMPARATORS, 'scopes' => AlertRule::SCOPES, ])->title($this->title()); } }