226 lines
9.5 KiB
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) ?: [])));
|
|
}
|
|
}
|