can('manage-panel'), 403); $this->emailTo = (string) Setting::get('alert_email_to', ''); $this->webhooks = (string) Setting::get('alert_webhooks', ''); } 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; } } Setting::put('alert_email_to', trim($this->emailTo)); Setting::put('alert_webhooks', trim($this->webhooks)); $this->audit('alert.channels_update', __('alerts.channels')); $this->dispatch('notify', message: __('alerts.channels_saved')); } public function render(): View { return view('livewire.alerts.index', [ '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()); } }