260 lines
9.5 KiB
PHP
260 lines
9.5 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Alerts;
|
|
|
|
use App\Models\AlertIncident;
|
|
use App\Models\AlertRule;
|
|
use App\Models\AuditEvent;
|
|
use App\Models\Server;
|
|
use App\Models\ServerGroup;
|
|
use App\Models\Setting;
|
|
use App\Services\AlertNotifier;
|
|
use App\Support\Confirm\ConfirmToken;
|
|
use App\Support\Confirm\InvalidConfirmToken;
|
|
use App\Support\MailSettings;
|
|
use Illuminate\Contracts\View\View;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Validation\Rule;
|
|
use Livewire\Attributes\Layout;
|
|
use Livewire\Attributes\On;
|
|
use Livewire\Component;
|
|
|
|
/**
|
|
* Alert rules + notification channels + active incidents. Managing rules/channels =
|
|
* manage-panel (admin): route mw + mount() + a per-method gate() (since /livewire/update
|
|
* skips route mw). Viewing incidents is open (read-only). Rule deletion goes through the
|
|
* signed single-use ConfirmToken + the R5 confirm modal.
|
|
*/
|
|
#[Layout('layouts.app')]
|
|
class Index extends Component
|
|
{
|
|
// New-rule form
|
|
public string $name = '';
|
|
|
|
public string $metric = 'cpu';
|
|
|
|
public string $comparator = 'gt';
|
|
|
|
public float $threshold = 80; // float: `load` thresholds are fractional (e.g. 1.5)
|
|
|
|
public string $scopeType = 'all';
|
|
|
|
public ?string $scopeUuid = null; // group OR server uuid, resolved on create
|
|
|
|
// Channels
|
|
public string $emailTo = '';
|
|
|
|
public string $webhooks = '';
|
|
|
|
// Gotify push: base server URL (plaintext) + app token (stored encrypted, write-only in the UI).
|
|
public string $gotifyUrl = '';
|
|
|
|
public string $gotifyToken = '';
|
|
|
|
public bool $gotifyTokenSet = false;
|
|
|
|
public function mount(): void
|
|
{
|
|
abort_unless(Auth::user()?->can('manage-panel'), 403);
|
|
$this->emailTo = (string) Setting::get('alert_email_to', '');
|
|
$this->webhooks = (string) Setting::get('alert_webhooks', '');
|
|
$this->gotifyUrl = (string) Setting::get('alert_gotify_url', '');
|
|
$this->gotifyTokenSet = ((string) Setting::get('alert_gotify_token', '')) !== '';
|
|
}
|
|
|
|
public function title(): string
|
|
{
|
|
return __('alerts.title');
|
|
}
|
|
|
|
private function gate(): void
|
|
{
|
|
abort_unless(Auth::user()?->can('manage-panel'), 403);
|
|
}
|
|
|
|
private function audit(string $action, string $target): void
|
|
{
|
|
AuditEvent::create([
|
|
'user_id' => Auth::id(),
|
|
'actor' => Auth::user()?->name ?? 'system',
|
|
'action' => $action,
|
|
'target' => $target,
|
|
'ip' => request()->ip(),
|
|
]);
|
|
}
|
|
|
|
public function createRule(): void
|
|
{
|
|
$this->gate();
|
|
|
|
$data = $this->validate([
|
|
'name' => ['required', 'string', 'max:80'],
|
|
'metric' => ['required', Rule::in(AlertRule::METRICS)],
|
|
'comparator' => ['required', Rule::in(AlertRule::COMPARATORS)],
|
|
'threshold' => ['required', 'numeric', 'min:0', 'max:10000'], // numeric: `load` can be fractional
|
|
'scopeType' => ['required', Rule::in(AlertRule::SCOPES)],
|
|
]);
|
|
|
|
// Resolve the scope target (a group/server uuid) to its id — never trust a client-sent id.
|
|
$scopeId = null;
|
|
if ($data['scopeType'] === 'group') {
|
|
$scopeId = ServerGroup::where('uuid', $this->scopeUuid)->firstOrFail()->id;
|
|
} elseif ($data['scopeType'] === 'server') {
|
|
$scopeId = Server::where('uuid', $this->scopeUuid)->firstOrFail()->id;
|
|
}
|
|
|
|
$rule = AlertRule::create([
|
|
'name' => $data['name'],
|
|
'metric' => $data['metric'],
|
|
'comparator' => $data['comparator'],
|
|
'threshold' => $data['metric'] === 'offline' ? 0 : $data['threshold'],
|
|
'scope_type' => $data['scopeType'],
|
|
'scope_id' => $scopeId,
|
|
'enabled' => true,
|
|
]);
|
|
|
|
$this->audit('alert.rule_create', $rule->name);
|
|
$this->reset('name', 'scopeUuid');
|
|
$this->dispatch('notify', message: __('alerts.rule_created', ['name' => $rule->name]));
|
|
}
|
|
|
|
public function toggleRule(string $uuid): void
|
|
{
|
|
$this->gate();
|
|
$rule = AlertRule::where('uuid', $uuid)->firstOrFail();
|
|
$rule->update(['enabled' => ! $rule->enabled]);
|
|
$this->audit('alert.rule_update', $rule->name);
|
|
}
|
|
|
|
public function confirmDeleteRule(string $uuid): void
|
|
{
|
|
$this->gate();
|
|
$rule = AlertRule::where('uuid', $uuid)->firstOrFail();
|
|
|
|
$this->dispatch('openModal',
|
|
component: 'modals.confirm-action',
|
|
arguments: [
|
|
'heading' => __('alerts.delete_heading'),
|
|
'body' => __('alerts.delete_body', ['name' => $rule->name]),
|
|
'confirmLabel' => __('common.delete'),
|
|
'danger' => true,
|
|
'icon' => 'trash',
|
|
'notify' => __('alerts.rule_deleted', ['name' => $rule->name]),
|
|
'token' => ConfirmToken::issue('alertRuleDeleted', ['uuid' => $rule->uuid], 'alert.rule_delete', $rule->name, null),
|
|
],
|
|
);
|
|
}
|
|
|
|
#[On('alertRuleDeleted')]
|
|
public function deleteRule(string $confirmToken): void
|
|
{
|
|
$this->gate();
|
|
|
|
try {
|
|
$payload = ConfirmToken::consume($confirmToken, 'alertRuleDeleted');
|
|
} catch (InvalidConfirmToken) {
|
|
return;
|
|
}
|
|
|
|
// NOT audited here — the confirm modal wrote alert.rule_delete from the sealed token.
|
|
AlertRule::where('uuid', $payload['params']['uuid'])->first()?->delete(); // incidents cascade
|
|
}
|
|
|
|
public function saveChannels(): void
|
|
{
|
|
$this->gate();
|
|
|
|
// SSRF guard: refuse to store a webhook whose scheme isn't http(s) or whose host resolves to
|
|
// an internal/loopback/private address. Checked here (immediate feedback) and again at send.
|
|
foreach (preg_split('/[\r\n]+/', trim($this->webhooks)) ?: [] as $line) {
|
|
$line = trim($line);
|
|
if ($line !== '' && ! AlertNotifier::isSafeWebhookUrl($line)) {
|
|
$this->addError('webhooks', __('alerts.webhook_invalid', ['url' => $line]));
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Gotify: scheme-checked (a self-hosted server on a LAN is allowed — no private-range block).
|
|
$gotifyUrl = trim($this->gotifyUrl);
|
|
if ($gotifyUrl !== '' && ! AlertNotifier::isValidGotifyUrl($gotifyUrl)) {
|
|
$this->addError('gotifyUrl', __('alerts.gotify_invalid'));
|
|
|
|
return;
|
|
}
|
|
|
|
$newToken = trim($this->gotifyToken);
|
|
$urlChanged = $gotifyUrl !== (string) Setting::get('alert_gotify_url', '');
|
|
|
|
// If the URL changes, DROP the old token BEFORE the new URL is stored — so no concurrent
|
|
// send can ever pair the old token with the new (possibly attacker-controlled) endpoint. A
|
|
// fresh token (if entered) is written afterwards; otherwise re-entry is required.
|
|
if ($urlChanged) {
|
|
Setting::forget('alert_gotify_token');
|
|
$this->gotifyTokenSet = false;
|
|
}
|
|
|
|
Setting::put('alert_email_to', trim($this->emailTo));
|
|
Setting::put('alert_webhooks', trim($this->webhooks));
|
|
Setting::put('alert_gotify_url', $gotifyUrl);
|
|
|
|
// Encrypt a NEWLY entered token; an empty field on an UNCHANGED url leaves the stored one.
|
|
if ($newToken !== '') {
|
|
Setting::put('alert_gotify_token', Crypt::encryptString($newToken));
|
|
$this->gotifyToken = '';
|
|
$this->gotifyTokenSet = true;
|
|
}
|
|
|
|
$this->audit('alert.channels_update', __('alerts.channels'));
|
|
$this->dispatch('notify', message: __('alerts.channels_saved'));
|
|
}
|
|
|
|
/** Remove the stored Gotify app token (write-only credential). */
|
|
public function clearGotifyToken(): void
|
|
{
|
|
$this->gate();
|
|
Setting::forget('alert_gotify_token');
|
|
$this->gotifyToken = '';
|
|
$this->gotifyTokenSet = false;
|
|
$this->audit('alert.channels_update', __('alerts.channels'));
|
|
$this->dispatch('notify', message: __('alerts.channels_saved'));
|
|
}
|
|
|
|
/** True when at least one delivery channel could actually reach someone (drives the warning).
|
|
* Reads the SAVED config with the SAME validation the sender uses, so the banner never hides
|
|
* for a channel that would be silently skipped at send time. */
|
|
private function channelsReach(): bool
|
|
{
|
|
if (MailSettings::isConfigured()) {
|
|
return true;
|
|
}
|
|
$webhooks = array_filter(
|
|
array_map('trim', preg_split('/[\r\n,]+/', (string) Setting::get('alert_webhooks', '')) ?: []),
|
|
fn (string $u) => $u !== '' && AlertNotifier::isSafeWebhookUrl($u),
|
|
);
|
|
if ($webhooks !== []) {
|
|
return true;
|
|
}
|
|
|
|
return AlertNotifier::gotifyTarget() !== null;
|
|
}
|
|
|
|
public function render(): View
|
|
{
|
|
return view('livewire.alerts.index', [
|
|
'channelsReach' => $this->channelsReach(),
|
|
'rules' => AlertRule::orderBy('name')->get(),
|
|
'incidents' => AlertIncident::with(['rule', 'server'])->where('state', 'firing')->latest('started_at')->limit(50)->get(),
|
|
'groups' => ServerGroup::orderBy('name')->get(['uuid', 'name']),
|
|
'servers' => Server::orderBy('name')->get(['uuid', 'name']),
|
|
// id → name maps so a rule's scope target renders without a per-rule query (no N+1).
|
|
'groupNames' => ServerGroup::pluck('name', 'id'),
|
|
'serverNames' => Server::pluck('name', 'id'),
|
|
'metrics' => AlertRule::METRICS,
|
|
'comparators' => AlertRule::COMPARATORS,
|
|
'scopes' => AlertRule::SCOPES,
|
|
])->title($this->title());
|
|
}
|
|
}
|