email($incident, $resolved); $this->webhooks($incident, $resolved); $this->gotify($incident, $resolved); } /** * Gotify push (self-hostable push server): POST /message with the app token in the X-Gotify-Key * header + a {title, message, priority} body — the shape Gotify expects (the generic webhook's * bare `text` field would not render). A firing alert uses a high priority (8) so it pushes/rings; * a resolve is low (3). Best-effort like every channel. Unlike the SSRF-guarded webhooks a Gotify * server is COMMONLY on a private LAN, so only the scheme is enforced (isValidGotifyUrl), not the * private-range block — that is the operator's deliberate, admin-only choice. */ private function gotify(AlertIncident $incident, bool $resolved): void { // Read + validate + decrypt ONCE and send exactly that pair — no re-read of the settings // between the check and the POST (which could otherwise pair a new URL with an old token). $target = self::gotifyTarget(); if ($target === null) { return; } $url = $target['url']; $token = $target['token']; $server = $incident->server?->name ?? '—'; $rule = $incident->rule?->name ?? '—'; $title = ($resolved ? __('alerts.mail_subject_resolved') : __('alerts.mail_subject_firing'))." — {$server}"; try { Http::timeout(5)->withoutRedirecting() ->withHeaders(['X-Gotify-Key' => $token]) ->asJson() ->post(rtrim($url, '/').'/message', [ 'title' => $title, 'message' => "{$rule}: {$incident->rule?->metric} = {$incident->value} (Limit {$incident->rule?->threshold})", 'priority' => $resolved ? 3 : 8, ]); } catch (Throwable $e) { Log::warning('alert gotify failed: '.$e->getMessage()); } } /** http(s) + a parseable host. NO private-range block (a self-hosted Gotify is usually on a LAN). */ public static function isValidGotifyUrl(string $url): bool { $p = parse_url(trim($url)); return is_array($p) && isset($p['scheme'], $p['host']) && in_array(strtolower($p['scheme']), ['http', 'https'], true); } /** * The validated Gotify target the sender will use — ['url' => string, 'token' => plaintext] — or * null when Gotify can't actually send (missing/invalid URL, or a missing/undecryptable token). * ONE place reads + validates + decrypts, so the "no delivery channel" banner (which checks for * null) and the send path can never diverge, and the POST uses exactly the pair that was * validated in the same read — no check-then-reread TOCTOU. * * @return array{url: string, token: string}|null */ public static function gotifyTarget(): ?array { // Setting::uncached (DB-direct, cache-bypass): a stale cached token read just after a URL change // could otherwise pair an old token with a new endpoint (token exfil). Reads the committed // state, which — with saveChannels dropping the token before writing the new URL — can never // present a new-url + old-token pair. $url = trim((string) Setting::uncached('alert_gotify_url', '')); $stored = (string) Setting::uncached('alert_gotify_token', ''); if ($url === '' || $stored === '' || ! self::isValidGotifyUrl($url)) { return null; } try { return ['url' => $url, 'token' => Crypt::decryptString($stored)]; } catch (Throwable) { return null; // undecryptable (rotated APP_KEY) — treat as not configured } } 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) ?: []))); } }