141 lines
5.0 KiB
PHP
141 lines
5.0 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 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
|
|
{
|
|
$this->email($incident, $resolved);
|
|
$this->webhooks($incident, $resolved);
|
|
}
|
|
|
|
private function email(AlertIncident $incident, bool $resolved): void
|
|
{
|
|
try {
|
|
$recipients = $this->recipients();
|
|
if ($recipients === []) {
|
|
return;
|
|
}
|
|
Mail::to($recipients)->queue(new AlertNotification($incident, $resolved));
|
|
} catch (Throwable $e) {
|
|
Log::warning('alert email failed: '.$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) ?: [])));
|
|
}
|
|
}
|