Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 81 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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=<hex>` — 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:
Expand Down
7 changes: 6 additions & 1 deletion app/Enums/NotificationChannelType.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
namespace App\Enums;

use App\Notifications\Channels\SlackWebhookChannel;
use App\Notifications\Channels\WebhookChannel;
use NotificationChannels\Pushover\PushoverChannel;

enum NotificationChannelType: string
{
case Email = 'email';
case Pushover = 'pushover';
case Slack = 'slack';
case Webhook = 'webhook';

/**
* Human-readable label for the channel.
Expand All @@ -22,6 +24,7 @@ public function label(): string
self::Email => 'Email',
self::Pushover => 'Pushover',
self::Slack => 'Slack',
self::Webhook => 'Webhook',
};
}

Expand All @@ -34,6 +37,7 @@ public function laravelChannel(): string
self::Email => 'mail',
self::Pushover => PushoverChannel::class,
self::Slack => SlackWebhookChannel::class,
self::Webhook => WebhookChannel::class,
};
}

Expand All @@ -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,
};
}
}
129 changes: 129 additions & 0 deletions app/Livewire/Settings/Notifications.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<int, array{label: string, url: string, secret: string, is_active: bool}>
*/
public array $webhookEdits = [];

public string $newWebhookLabel = '';

public string $newWebhookUrl = '';

public bool $newWebhookActive = true;

public ?int $defaultChannelId = null;

public function mount(): void
Expand All @@ -52,6 +67,7 @@ public function mount(): void
}

$this->refreshSlackEdits();
$this->refreshWebhookEdits();

$this->defaultChannelId = $user->notificationChannels()
->where('is_default', true)
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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(),
]);
}

Expand All @@ -248,4 +352,29 @@ protected function refreshSlackEdits(): void
])
->all();
}

/**
* @return array<int, string>
*/
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();
}
}
21 changes: 21 additions & 0 deletions app/Models/NotificationChannel.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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),
};
}
}
Loading
Loading