clusev/app/Services/AlertNotifier.php

226 lines
9.5 KiB
PHP

<?php
namespace App\Services;
use App\Enums\Role;
use App\Mail\AlertNotification;
use App\Models\AlertIncident;
use App\Models\Setting;
use App\Models\User;
use App\Support\MailSettings;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
/**
* Fans one alert transition (fire / resolve) out to the configured channels. Every channel is
* BEST-EFFORT: a broken SMTP config or an unreachable webhook is logged, never thrown, so a
* notification failure can never break the poll loop that triggered it.
*/
class AlertNotifier
{
public function notify(AlertIncident $incident, bool $resolved): void
{
// Re-apply the DB SMTP settings RIGHT BEFORE queueing: the metrics poller calling this is a
// long-lived process, and Laravel bakes the current default MAILER NAME into the mailable at
// queue time — without this, a poller that booted before SMTP was configured would bake the
// `log` mailer into every alert e-mail forever (they'd land in laravel.log, never the inbox).
MailSettings::apply();
$this->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<string> */
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<string> */
private function webhookUrls(): array
{
return array_values(array_filter(
$this->splitLines((string) Setting::get('alert_webhooks', '')),
fn (string $u) => self::isSafeWebhookUrl($u),
));
}
/** @return array<string, mixed> */
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<string> */
private function splitLines(string $raw): array
{
return array_values(array_filter(array_map('trim', preg_split('/[\r\n,]+/', $raw) ?: [])));
}
}