email($incident, $resolved); $this->webhooks($incident, $resolved); } private function email(AlertIncident $incident, bool $resolved): void { // ONE message per recipient — NOT a single To: with everyone. A mixed To that contains an // undeliverable address (e.g. a seeded admin on a non-routable domain) reads as spam to // providers like Gmail and gets the WHOLE message silently dropped for the valid recipients // too. Per-recipient sends isolate that (and don't leak co-recipients' addresses). Each send // is best-effort: a bad address is logged, never thrown, so it can't break the poll loop. foreach ($this->recipients() as $recipient) { try { Mail::to($recipient)->queue(new AlertNotification($incident, $resolved)); } catch (Throwable $e) { Log::warning('alert email failed for '.$recipient.': '.$e->getMessage()); } } } private function webhooks(AlertIncident $incident, bool $resolved): void { $payload = $this->payload($incident, $resolved); foreach ($this->webhookUrls() as $url) { try { // withoutRedirecting: a 30x could otherwise bounce the POST to an internal target that // the SSRF host-check below never saw. timeout keeps a slow hook from stalling the loop. Http::timeout(5)->withoutRedirecting()->asJson()->post($url, $payload); } catch (Throwable $e) { Log::warning('alert webhook failed: '.$e->getMessage()); } } } /** * SSRF guard for an operator-configured webhook URL: exactly http(s), and the host must NOT * resolve to a loopback/private/link-local/reserved address (so a hook can't be pointed at an * internal service). Admin-only config, but defence in depth — checked here AND at save time. */ public static function isSafeWebhookUrl(string $url): bool { $parts = parse_url(trim($url)); if ($parts === false || ! isset($parts['scheme'], $parts['host'])) { return false; } if (! in_array(strtolower($parts['scheme']), ['http', 'https'], true)) { return false; } $host = $parts['host']; // IP literal → itself; hostname → its A records (IPv4). Unresolvable / IPv6-only → refuse. $ips = filter_var($host, FILTER_VALIDATE_IP) ? [$host] : (gethostbynamel($host) ?: []); if ($ips === []) { return false; } foreach ($ips as $ip) { if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) { return false; // loopback/private/link-local/reserved → blocked } } return true; } /** @return list */ private function recipients(): array { $configured = $this->splitLines((string) Setting::get('alert_email_to', '')); $emails = array_values(array_filter($configured, fn (string $e) => filter_var($e, FILTER_VALIDATE_EMAIL) !== false)); if ($emails !== []) { return $emails; } // Fall back to every admin's e-mail so a fresh install still alerts someone. return User::query()->where('role', Role::Admin->value) ->whereNotNull('email') ->pluck('email')->all(); } /** @return list */ private function webhookUrls(): array { return array_values(array_filter( $this->splitLines((string) Setting::get('alert_webhooks', '')), fn (string $u) => self::isSafeWebhookUrl($u), )); } /** @return array */ private function payload(AlertIncident $incident, bool $resolved): array { $server = $incident->server?->name ?? '—'; $rule = $incident->rule?->name ?? '—'; $state = $resolved ? 'resolved' : 'firing'; $text = $resolved ? __('alerts.mail_subject_resolved')." — {$rule} ({$server})" : __('alerts.mail_subject_firing')." — {$rule} ({$server})"; // A single `text` field satisfies Slack/Discord/Mattermost/Telegram-relay incoming webhooks; // the structured fields let a custom consumer route on server/metric/state. return [ 'text' => $text, 'state' => $state, 'server' => $server, 'rule' => $rule, 'metric' => $incident->rule?->metric, 'value' => $incident->value, 'threshold' => $incident->rule?->threshold, ]; } /** @return list */ private function splitLines(string $raw): array { return array_values(array_filter(array_map('trim', preg_split('/[\r\n,]+/', $raw) ?: []))); } }