diff --git a/README.md b/README.md index 6cfb421..de15842 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ EasyMonitor is a full-stack monitoring platform for your websites and APIs. Add - **HTTP and ICMP checks** — every 30 seconds to 1 hour per monitor - **Multi-region probes** — lightweight Go binaries (~10 MB) you can deploy anywhere - **Consecutive-failure threshold** — configurable per monitor; no alerts on flaky single failures -- **Multi-channel alerts** — email, Slack, and Pushover (per-user, per-monitor selection) on down and recovery +- **Multi-channel alerts** — email, Slack, generic webhooks (HMAC-signed), and Pushover (per-user, per-monitor selection) on down and recovery - **Projects** — group related monitors (e.g. main site + APIs) - **Teams** — share monitors and projects with collaborators with role-based access - **Status pages** — public, unlisted (secret link), or private @@ -149,10 +149,90 @@ Supported channels: |---------|-------|-----------------| | Email | Configured by the admin via `MAIL_MAILER` (log, SES, SMTP) | Uses the account email | | Slack | No admin setup — Slack-side only | User adds one or more [incoming webhooks](https://api.slack.com/messaging/webhooks), each labelled (e.g. `#alerts-api`, `#alerts-frontend`) — pick which ones to alert per monitor | +| Webhook | No admin setup | User adds one or more HTTP endpoints (any URL) — each gets an auto-generated HMAC-SHA256 secret. Payloads are signed with `X-EasyMonitor-Signature: sha256=…` and tagged with `X-EasyMonitor-Event: monitor.down\|monitor.recovered`. Pipe to PagerDuty, Zapier, n8n, custom services | | Pushover | Admin sets `PUSHOVER_APP_TOKEN` once (from [pushover.net/apps/build](https://pushover.net/apps/build)) | User pastes their user key (and optional device) | Send-test buttons on the Notifications page let each user verify their configuration end-to-end. +### Webhook payload + +Webhook deliveries are HTTP `POST` with `Content-Type: application/json`. Two events fire — one when a monitor crosses the failure threshold and one when it recovers. + +**Headers** + +| Header | Value | +|--------|-------| +| `X-EasyMonitor-Event` | `monitor.down` or `monitor.recovered` | +| `X-EasyMonitor-Signature` | `sha256=` — HMAC-SHA256 of the raw body using your channel's secret | +| `User-Agent` | `EasyMonitor-Webhook/1.0` | + +**`monitor.down` body** + +```json +{ + "event": "monitor.down", + "monitor": { + "id": 42, + "name": "Production API", + "url": "https://api.example.com/health", + "check_type": "http" + }, + "error": "Get \"https://api.example.com/health\": dial tcp: connection refused", + "checked_at": "2026-05-14T13:42:07+00:00", + "dashboard_url": "https://easymonitor.example.com/monitors/42" +} +``` + +`error` is `null` when the failure has no diagnostic message. `check_type` is `http` or `icmp`. + +**`monitor.recovered` body** + +```json +{ + "event": "monitor.recovered", + "monitor": { + "id": 42, + "name": "Production API", + "url": "https://api.example.com/health", + "check_type": "http" + }, + "checked_at": "2026-05-14T13:48:32+00:00", + "dashboard_url": "https://easymonitor.example.com/monitors/42" +} +``` + +**Verifying the signature** + +Compute HMAC-SHA256 over the *raw* request body using the secret shown in your channel settings, then compare against the `X-EasyMonitor-Signature` header (without the `sha256=` prefix) using a constant-time comparison. + +```python +import hmac, hashlib + +def verify(body: bytes, header: str, secret: str) -> bool: + expected = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() + return hmac.compare_digest(expected, header) +``` + +```php +$expected = 'sha256='.hash_hmac('sha256', $rawBody, $secret); +$ok = hash_equals($expected, $request->header('X-EasyMonitor-Signature')); +``` + +```js +import { createHmac, timingSafeEqual } from "node:crypto"; + +function verify(body, header, secret) { + const expected = "sha256=" + createHmac("sha256", secret).update(body).digest("hex"); + return timingSafeEqual(Buffer.from(expected), Buffer.from(header)); +} +``` + +Always sign the **raw bytes** of the body, not a re-serialized version — re-encoding can change byte-for-byte content (whitespace, key order) and the signature won't match. In Express, that means `express.raw({ type: 'application/json' })`. In Laravel, `$request->getContent()`. + +**Delivery semantics** + +Deliveries are best-effort with a 10s timeout. Failed deliveries are logged but not retried — design your receiver to be tolerant of duplicates if you queue/process asynchronously, and idempotent on `monitor.id + event + checked_at`. + ## Configuration Most settings live in `.env`. Notable ones beyond the standard Laravel set: diff --git a/app/Enums/NotificationChannelType.php b/app/Enums/NotificationChannelType.php index 8e7ff1a..6bba934 100644 --- a/app/Enums/NotificationChannelType.php +++ b/app/Enums/NotificationChannelType.php @@ -5,6 +5,7 @@ namespace App\Enums; use App\Notifications\Channels\SlackWebhookChannel; +use App\Notifications\Channels\WebhookChannel; use NotificationChannels\Pushover\PushoverChannel; enum NotificationChannelType: string @@ -12,6 +13,7 @@ enum NotificationChannelType: string case Email = 'email'; case Pushover = 'pushover'; case Slack = 'slack'; + case Webhook = 'webhook'; /** * Human-readable label for the channel. @@ -22,6 +24,7 @@ public function label(): string self::Email => 'Email', self::Pushover => 'Pushover', self::Slack => 'Slack', + self::Webhook => 'Webhook', }; } @@ -34,6 +37,7 @@ public function laravelChannel(): string self::Email => 'mail', self::Pushover => PushoverChannel::class, self::Slack => SlackWebhookChannel::class, + self::Webhook => WebhookChannel::class, }; } @@ -45,7 +49,8 @@ public function sortOrder(): int return match ($this) { self::Email => 0, self::Slack => 1, - self::Pushover => 2, + self::Webhook => 2, + self::Pushover => 3, }; } } diff --git a/app/Livewire/Settings/Notifications.php b/app/Livewire/Settings/Notifications.php index 6e7cae3..7a38cc1 100644 --- a/app/Livewire/Settings/Notifications.php +++ b/app/Livewire/Settings/Notifications.php @@ -11,6 +11,7 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Notification as NotificationFacade; +use Illuminate\Support\Str; use Livewire\Component; class Notifications extends Component @@ -35,6 +36,20 @@ class Notifications extends Component public bool $newSlackActive = true; + /** + * Editable in-place state for each existing Webhook channel, + * keyed by NotificationChannel id. + * + * @var array + */ + public array $webhookEdits = []; + + public string $newWebhookLabel = ''; + + public string $newWebhookUrl = ''; + + public bool $newWebhookActive = true; + public ?int $defaultChannelId = null; public function mount(): void @@ -52,6 +67,7 @@ public function mount(): void } $this->refreshSlackEdits(); + $this->refreshWebhookEdits(); $this->defaultChannelId = $user->notificationChannels() ->where('is_default', true) @@ -170,6 +186,93 @@ public function deleteSlackChannel(int $channelId): void $this->dispatch('notifications-saved'); } + public function addWebhookChannel(): void + { + $this->validate([ + 'newWebhookLabel' => ['required', 'string', 'max:50'], + 'newWebhookUrl' => $this->webhookUrlRules(), + 'newWebhookActive' => ['boolean'], + ]); + + Auth::user()->notificationChannels()->create([ + 'type' => NotificationChannelType::Webhook, + 'label' => $this->newWebhookLabel, + 'config' => [ + 'url' => $this->newWebhookUrl, + 'secret' => Str::random(64), + ], + 'is_active' => $this->newWebhookActive, + 'is_default' => false, + ]); + + $this->newWebhookLabel = ''; + $this->newWebhookUrl = ''; + $this->newWebhookActive = true; + + $this->refreshWebhookEdits(); + + $this->dispatch('notifications-saved'); + } + + public function saveWebhookChannel(int $channelId): void + { + $prefix = "webhookEdits.{$channelId}"; + + $this->validate([ + "{$prefix}.label" => ['required', 'string', 'max:50'], + "{$prefix}.url" => $this->webhookUrlRules(), + "{$prefix}.is_active" => ['boolean'], + ]); + + $row = $this->webhookEdits[$channelId]; + + $channel = Auth::user() + ->notificationChannels() + ->where('type', NotificationChannelType::Webhook->value) + ->findOrFail($channelId); + + // Preserve secret across edits; never let it be overwritten by the form. + $config = $channel->config ?? []; + $config['url'] = $row['url']; + + $channel->update([ + 'label' => $row['label'], + 'config' => $config, + 'is_active' => (bool) ($row['is_active'] ?? true), + ]); + + $this->dispatch('notifications-saved'); + } + + public function regenerateWebhookSecret(int $channelId): void + { + $channel = Auth::user() + ->notificationChannels() + ->where('type', NotificationChannelType::Webhook->value) + ->findOrFail($channelId); + + $config = $channel->config ?? []; + $config['secret'] = Str::random(64); + $channel->update(['config' => $config]); + + $this->refreshWebhookEdits(); + + $this->dispatch('notifications-saved'); + } + + public function deleteWebhookChannel(int $channelId): void + { + Auth::user() + ->notificationChannels() + ->where('type', NotificationChannelType::Webhook->value) + ->whereKey($channelId) + ->delete(); + + unset($this->webhookEdits[$channelId]); + + $this->dispatch('notifications-saved'); + } + public function setDefault(int $channelId): void { $user = Auth::user(); @@ -222,6 +325,7 @@ public function render() return view('livewire.settings.notifications', [ 'channels' => $channels, 'slackChannels' => $channels->where('type', NotificationChannelType::Slack)->values(), + 'webhookChannels' => $channels->where('type', NotificationChannelType::Webhook)->values(), ]); } @@ -248,4 +352,29 @@ protected function refreshSlackEdits(): void ]) ->all(); } + + /** + * @return array + */ + protected function webhookUrlRules(): array + { + return ['required', 'string', 'url', 'starts_with:https://,http://', 'max:500']; + } + + protected function refreshWebhookEdits(): void + { + $this->webhookEdits = Auth::user() + ->notificationChannels() + ->where('type', NotificationChannelType::Webhook->value) + ->get() + ->mapWithKeys(fn (NotificationChannel $channel) => [ + $channel->id => [ + 'label' => (string) $channel->label, + 'url' => (string) ($channel->config['url'] ?? ''), + 'secret' => (string) ($channel->config['secret'] ?? ''), + 'is_active' => (bool) $channel->is_active, + ], + ]) + ->all(); + } } diff --git a/app/Models/NotificationChannel.php b/app/Models/NotificationChannel.php index 894ab72..ed6ca74 100644 --- a/app/Models/NotificationChannel.php +++ b/app/Models/NotificationChannel.php @@ -93,6 +93,26 @@ public function routeNotificationForSlack(): ?string return is_string($webhookUrl) && $webhookUrl !== '' ? $webhookUrl : null; } + /** + * Route generic webhook notifications to the URL stored in config. + */ + public function routeNotificationForWebhook(): ?string + { + $url = $this->config['url'] ?? null; + + return is_string($url) && $url !== '' ? $url : null; + } + + /** + * The HMAC secret used to sign webhook deliveries. + */ + public function webhookSecret(): ?string + { + $secret = $this->config['secret'] ?? null; + + return is_string($secret) && $secret !== '' ? $secret : null; + } + /** * Whether the channel has the configuration it needs to send. */ @@ -102,6 +122,7 @@ public function isConfigured(): bool NotificationChannelType::Email => filled($this->user?->email), NotificationChannelType::Pushover => filled($this->config['user_key'] ?? null), NotificationChannelType::Slack => filled($this->config['webhook_url'] ?? null), + NotificationChannelType::Webhook => filled($this->config['url'] ?? null) && filled($this->config['secret'] ?? null), }; } } diff --git a/app/Notifications/Channels/WebhookChannel.php b/app/Notifications/Channels/WebhookChannel.php new file mode 100644 index 0000000..9379813 --- /dev/null +++ b/app/Notifications/Channels/WebhookChannel.php @@ -0,0 +1,56 @@ +toWebhook($notifiable); + $url = $notifiable->routeNotificationFor('webhook', $notification); + $secret = $notifiable->webhookSecret(); + + if (! is_string($url) || $url === '' || ! is_array($payload) || ! is_string($secret) || $secret === '') { + return; + } + + $event = (string) ($payload['event'] ?? 'monitor.event'); + $body = json_encode($payload, JSON_UNESCAPED_SLASHES); + $signature = hash_hmac('sha256', $body, $secret); + + $response = Http::withHeaders([ + 'Content-Type' => 'application/json', + 'User-Agent' => 'EasyMonitor-Webhook/1.0', + 'X-EasyMonitor-Event' => $event, + 'X-EasyMonitor-Signature' => 'sha256='.$signature, + ]) + ->timeout(10) + ->withBody($body, 'application/json') + ->post($url); + + if ($response->failed()) { + Log::warning('Webhook delivery failed', [ + 'url' => $url, + 'status' => $response->status(), + 'body' => $response->body(), + ]); + } + } +} diff --git a/app/Notifications/MonitorDown.php b/app/Notifications/MonitorDown.php index a116ec4..727227a 100644 --- a/app/Notifications/MonitorDown.php +++ b/app/Notifications/MonitorDown.php @@ -109,6 +109,27 @@ public function toSlack(object $notifiable): array return ['text' => $fallback, 'blocks' => $blocks]; } + /** + * Generic webhook payload — signed and POSTed by WebhookChannel. + * + * @return array + */ + public function toWebhook(object $notifiable): array + { + return [ + 'event' => 'monitor.down', + 'monitor' => [ + 'id' => $this->monitor->id, + 'name' => $this->monitor->name, + 'url' => $this->monitor->url, + 'check_type' => $this->monitor->check_type?->value, + ], + 'error' => $this->errorMessage, + 'checked_at' => $this->monitor->last_checked_at?->toIso8601String(), + 'dashboard_url' => url("/monitors/{$this->monitor->id}"), + ]; + } + /** * @return array */ diff --git a/app/Notifications/MonitorRecovered.php b/app/Notifications/MonitorRecovered.php index d043e10..762972e 100644 --- a/app/Notifications/MonitorRecovered.php +++ b/app/Notifications/MonitorRecovered.php @@ -94,6 +94,26 @@ public function toSlack(object $notifiable): array return ['text' => $fallback, 'blocks' => $blocks]; } + /** + * Generic webhook payload — signed and POSTed by WebhookChannel. + * + * @return array + */ + public function toWebhook(object $notifiable): array + { + return [ + 'event' => 'monitor.recovered', + 'monitor' => [ + 'id' => $this->monitor->id, + 'name' => $this->monitor->name, + 'url' => $this->monitor->url, + 'check_type' => $this->monitor->check_type?->value, + ], + 'checked_at' => $this->monitor->last_checked_at?->toIso8601String(), + 'dashboard_url' => url("/monitors/{$this->monitor->id}"), + ]; + } + /** * @return array */ diff --git a/database/factories/NotificationChannelFactory.php b/database/factories/NotificationChannelFactory.php index 34bfa42..eb5ed92 100644 --- a/database/factories/NotificationChannelFactory.php +++ b/database/factories/NotificationChannelFactory.php @@ -55,6 +55,18 @@ public function slack(?string $webhookUrl = null, ?string $label = null): static ]); } + public function webhook(?string $url = null, ?string $label = null, ?string $secret = null): static + { + return $this->state(fn () => [ + 'type' => NotificationChannelType::Webhook, + 'label' => $label ?? 'PagerDuty', + 'config' => [ + 'url' => $url ?? 'https://example.com/hooks/easymonitor', + 'secret' => $secret ?? str_repeat('s', 64), + ], + ]); + } + public function default(): static { return $this->state(fn () => ['is_default' => true]); diff --git a/resources/views/livewire/settings/notifications.blade.php b/resources/views/livewire/settings/notifications.blade.php index d866c8c..43d2994 100644 --- a/resources/views/livewire/settings/notifications.blade.php +++ b/resources/views/livewire/settings/notifications.blade.php @@ -196,6 +196,151 @@ class="input input-bordered input-sm rounded-lg font-mono text-xs @error('newSla + +
+
+
+

{{ __('Webhook') }}

+

+ {{ __('POST a JSON payload to any HTTP endpoint when a monitor goes down or recovers. Each webhook is signed with HMAC-SHA256; verify the') }} + X-EasyMonitor-Signature + {{ __('header using the secret shown after saving.') }} +

+
+ + @foreach ($webhookChannels as $existing) +
+
+
+ + + @error('webhookEdits.'.$existing->id.'.label') + {{ $message }} + @enderror +
+
+ + + @error('webhookEdits.'.$existing->id.'.url') + {{ $message }} + @enderror +
+
+ +
+ +
+ + + +
+
+ +
+ + +
+ + +
+
+
+ @endforeach + +
+
+ {{ $webhookChannels->isEmpty() ? __('Add your first webhook') : __('Add another webhook') }} +
+
+
+ + + @error('newWebhookLabel') + {{ $message }} + @enderror +
+
+ + + @error('newWebhookUrl') + {{ $message }} + @enderror +
+
+ +
+ + +
+ + {{ __('Saved.') }} + + +
+
+
+
+
+
diff --git a/tests/Feature/Feature/Settings/NotificationsSettingsTest.php b/tests/Feature/Feature/Settings/NotificationsSettingsTest.php index d1de4ff..07ca455 100644 --- a/tests/Feature/Feature/Settings/NotificationsSettingsTest.php +++ b/tests/Feature/Feature/Settings/NotificationsSettingsTest.php @@ -193,6 +193,87 @@ expect($user->notificationChannels()->whereKey($existing->id)->exists())->toBeFalse(); }); +test('adding a webhook channel generates a signing secret', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + Livewire::test(Notifications::class) + ->set('newWebhookLabel', 'PagerDuty') + ->set('newWebhookUrl', 'https://example.com/hook') + ->call('addWebhookChannel') + ->assertHasNoErrors(); + + $webhook = $user->notificationChannels() + ->where('type', \App\Enums\NotificationChannelType::Webhook->value) + ->first(); + + expect($webhook)->not->toBeNull(); + expect($webhook->label)->toBe('PagerDuty'); + expect($webhook->config['url'])->toBe('https://example.com/hook'); + expect($webhook->config['secret'])->toBeString(); + expect(strlen($webhook->config['secret']))->toBe(64); +}); + +test('webhook channel requires a label and a url', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + Livewire::test(Notifications::class) + ->call('addWebhookChannel') + ->assertHasErrors(['newWebhookLabel', 'newWebhookUrl']); +}); + +test('webhook channel rejects non-http(s) urls', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + Livewire::test(Notifications::class) + ->set('newWebhookLabel', 'PagerDuty') + ->set('newWebhookUrl', 'ftp://example.com/hook') + ->call('addWebhookChannel') + ->assertHasErrors(['newWebhookUrl']); +}); + +test('saving a webhook channel preserves the signing secret', function () { + $user = User::factory()->create(); + $existing = NotificationChannel::factory()->for($user)->webhook(secret: str_repeat('o', 64))->create(); + $this->actingAs($user); + + Livewire::test(Notifications::class) + ->set("webhookEdits.{$existing->id}.label", 'Renamed') + ->set("webhookEdits.{$existing->id}.url", 'https://example.com/new') + ->call('saveWebhookChannel', $existing->id); + + $existing->refresh(); + expect($existing->label)->toBe('Renamed'); + expect($existing->config['url'])->toBe('https://example.com/new'); + expect($existing->config['secret'])->toBe(str_repeat('o', 64)); +}); + +test('regenerating a webhook secret replaces it with a new random value', function () { + $user = User::factory()->create(); + $existing = NotificationChannel::factory()->for($user)->webhook(secret: str_repeat('o', 64))->create(); + $this->actingAs($user); + + Livewire::test(Notifications::class) + ->call('regenerateWebhookSecret', $existing->id); + + $existing->refresh(); + expect($existing->config['secret'])->not->toBe(str_repeat('o', 64)); + expect(strlen($existing->config['secret']))->toBe(64); +}); + +test('a webhook channel can be deleted', function () { + $user = User::factory()->create(); + $existing = NotificationChannel::factory()->for($user)->webhook()->create(); + $this->actingAs($user); + + Livewire::test(Notifications::class) + ->call('deleteWebhookChannel', $existing->id); + + expect($user->notificationChannels()->whereKey($existing->id)->exists())->toBeFalse(); +}); + test('a user cannot target another user\'s channel', function () { $user = User::factory()->create(); $other = User::factory()->create(); diff --git a/tests/Unit/Notifications/WebhookChannelTest.php b/tests/Unit/Notifications/WebhookChannelTest.php new file mode 100644 index 0000000..7972bd7 --- /dev/null +++ b/tests/Unit/Notifications/WebhookChannelTest.php @@ -0,0 +1,91 @@ +create(); + $channel = NotificationChannel::factory() + ->for($user) + ->webhook('https://example.com/hook', 'PagerDuty', $secret) + ->create(); + $monitor = Monitor::factory()->for($user)->create(['name' => 'API', 'url' => 'https://api.example.com']); + + NotificationFacade::sendNow([$channel], new MonitorDown($monitor, 'connection refused')); + + Http::assertSent(function ($request) use ($secret) { + $body = $request->body(); + $payload = json_decode($body, true); + $expectedSig = 'sha256='.hash_hmac('sha256', $body, $secret); + + return $request->url() === 'https://example.com/hook' + && $request->method() === 'POST' + && $request->header('X-EasyMonitor-Event')[0] === 'monitor.down' + && $request->header('X-EasyMonitor-Signature')[0] === $expectedSig + && $payload['event'] === 'monitor.down' + && $payload['monitor']['name'] === 'API' + && $payload['error'] === 'connection refused'; + }); +}); + +test('MonitorRecovered is posted with the recovered event', function () { + Http::fake(); + + $secret = str_repeat('k', 64); + $user = User::factory()->create(); + $channel = NotificationChannel::factory() + ->for($user) + ->webhook('https://example.com/hook', 'PagerDuty', $secret) + ->create(); + $monitor = Monitor::factory()->for($user)->create(['name' => 'API']); + + NotificationFacade::sendNow([$channel], new MonitorRecovered($monitor)); + + Http::assertSent(function ($request) { + return $request->header('X-EasyMonitor-Event')[0] === 'monitor.recovered' + && json_decode($request->body(), true)['event'] === 'monitor.recovered'; + }); +}); + +test('a webhook channel without a url is skipped', function () { + Http::fake(); + + $user = User::factory()->create(); + $channel = NotificationChannel::factory()->for($user)->webhook()->create(); + $channel->update(['config' => ['secret' => str_repeat('k', 64)]]); + $channel->refresh(); + + $monitor = Monitor::factory()->for($user)->create(); + + NotificationFacade::sendNow([$channel], new MonitorRecovered($monitor)); + + Http::assertNothingSent(); +}); + +test('a webhook channel without a secret is skipped', function () { + Http::fake(); + + $user = User::factory()->create(); + $channel = NotificationChannel::factory()->for($user)->webhook()->create(); + $channel->update(['config' => ['url' => 'https://example.com/hook']]); + $channel->refresh(); + + $monitor = Monitor::factory()->for($user)->create(); + + NotificationFacade::sendNow([$channel], new MonitorRecovered($monitor)); + + Http::assertNothingSent(); +});