diff --git a/README.md b/README.md index 56935cb..20bd012 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A self-hosted, full-stack network management platform for MikroTik devices. Monitor, configure, and manage your entire MikroTik infrastructure — routers, switches, and wireless access points — from a single web interface. -![Version](https://img.shields.io/badge/version-0.20.1_Beta-blue) +![Version](https://img.shields.io/badge/version-0.21.0_Beta-blue) ![License](https://img.shields.io/badge/license-AGPLv3-blue) ![Docker](https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white) @@ -327,7 +327,16 @@ Configurable alert rules with cooldown periods: - New device discovered - Configuration changed (drift detection) -Alert delivery channels: **Email**, **Slack**, **Discord**, **Telegram** +Alert delivery channels: **Email**, **Slack**, **Discord**, **Telegram**, **ntfy** + +**ntfy** (`https://ntfy.sh` or your own instance) is configured with a topic and, optionally, an +access token or username/password — a publicly-writable topic lets anyone spoof your alerts, so +protecting it is worth the extra field. Alert severity maps onto ntfy's priority scale so the +notifications that mean something is *down* (device offline, log errors, CPU/memory pressure) arrive +at priority 4 and break through a phone's do-not-disturb, recoveries and discoveries arrive quietly +at 2, and everything else sits at the default 3. Each notification is tagged with its event type, so +a client can be filtered to wake only for the events you care about. Set the optional **Manager URL** +and notifications become tappable, opening the device they refer to. ### Maintenance Windows - Schedule planned downtime windows per device or group of devices to suppress alerts automatically diff --git a/backend/package.json b/backend/package.json index 360b807..4c6a145 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-backend", - "version": "0.20.1-beta", + "version": "0.21.0-beta", "description": "Mikrotik Network Management Platform - Backend API", "main": "dist/index.js", "scripts": { diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 0d93c91..4112a8c 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -283,7 +283,7 @@ CREATE TABLE IF NOT EXISTS alert_rules ( CREATE TABLE IF NOT EXISTS alert_channels ( id SERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL, - type VARCHAR(20) NOT NULL CHECK (type IN ('email','slack','discord','telegram')), + type VARCHAR(20) NOT NULL CHECK (type IN ('email','slack','discord','telegram','ntfy')), enabled BOOLEAN NOT NULL DEFAULT true, config JSONB NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT NOW(), @@ -498,6 +498,12 @@ CREATE TABLE IF NOT EXISTS device_change_guards ( CREATE INDEX IF NOT EXISTS idx_change_guards_device ON device_change_guards(device_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_change_guards_pending ON device_change_guards(status, expires_at); +-- ntfy.sh notification channel (github issue #93). The channel type is a CHECK +-- constraint rather than a lookup table, so widening it means replacing it. +ALTER TABLE alert_channels DROP CONSTRAINT IF EXISTS alert_channels_type_check; +ALTER TABLE alert_channels ADD CONSTRAINT alert_channels_type_check + CHECK (type IN ('email','slack','discord','telegram','ntfy')); + -- Change Guard ledger: committed_at used to be stamped for every terminal status, -- so an abandoned or failed guard read as though the change had been kept. It now -- means what it says, and resolved_at records when the guard finished either way. diff --git a/backend/src/routes/alerts.ts b/backend/src/routes/alerts.ts index 8d41b20..6568691 100644 --- a/backend/src/routes/alerts.ts +++ b/backend/src/routes/alerts.ts @@ -81,7 +81,7 @@ router.post('/channels', requireWrite, async (req, res) => { if (!name || !type) { return res.status(400).json({ error: 'name and type are required' }); } - const validTypes = ['email', 'slack', 'discord', 'telegram']; + const validTypes = ['email', 'slack', 'discord', 'telegram', 'ntfy']; if (!validTypes.includes(type)) { return res.status(400).json({ error: `type must be one of: ${validTypes.join(', ')}` }); } @@ -171,6 +171,8 @@ const SENSITIVE_KEYS: Record = { slack: [], discord: [], telegram: ['bot_token'], + // ntfy accepts either an access token or basic auth; both are secrets. + ntfy: ['token', 'password'], }; function maskConfig(type: string, config: Record): Record { diff --git a/backend/src/services/AlertService.ts b/backend/src/services/AlertService.ts index 76ed186..50eecbd 100644 --- a/backend/src/services/AlertService.ts +++ b/backend/src/services/AlertService.ts @@ -33,7 +33,7 @@ interface AlertRule { interface AlertChannel { id: number; name: string; - type: 'email' | 'slack' | 'discord' | 'telegram'; + type: 'email' | 'slack' | 'discord' | 'telegram' | 'ntfy'; enabled: boolean; config: Record; } @@ -203,6 +203,7 @@ export class AlertService { case 'slack': await this.sendSlack(ch.config, eventType, message, ctx); break; case 'discord': await this.sendDiscord(ch.config, eventType, message, ctx); break; case 'telegram': await this.sendTelegram(ch.config, eventType, message, ctx); break; + case 'ntfy': await this.sendNtfy(ch.config, eventType, message, ctx); break; } } @@ -329,9 +330,96 @@ export class AlertService { await this.postJson(url, body); } + /** + * ntfy (https://ntfy.sh, or a self-hosted instance). + * + * Published as JSON to the server root rather than `POST /{topic}` with header + * metadata: the header form requires ASCII, and the titles here carry per-event + * emoji. The JSON body is UTF-8 and keeps the emoji intact. + * + * `server_url` is operator-supplied so self-hosted instances work — which is the + * common case for infrastructure alerting. That is the same trust model as the + * existing Slack and Discord webhook URLs: an admin can point any of them at an + * internal address, and only an admin can configure them. + */ + private async sendNtfy( + cfg: Record, + eventType: string, + message: string, + ctx: AlertContext + ): Promise { + const topic = (cfg.topic as string | undefined)?.trim(); + if (!topic) throw new Error('ntfy channel missing topic'); + + const serverUrl = ((cfg.server_url as string | undefined)?.trim() || 'https://ntfy.sh').replace(/\/+$/, ''); + let parsed: URL; + try { + parsed = new URL(serverUrl); + } catch { + throw new Error(`ntfy channel has an invalid server_url: ${serverUrl}`); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error('ntfy server_url must be http or https'); + } + + const label = EVENT_LABELS[eventType] ?? eventType; + const emoji = EVENT_EMOJI[eventType] ?? '\u{1F514}'; + + const lines = [message]; + if (ctx.deviceName) lines.push(`Device: ${ctx.deviceName}`); + if (ctx.details) lines.push(ctx.details); + + const body: Record = { + topic, + title: `${emoji} ${label}`, + message: lines.join('\n'), + priority: this.ntfyPriority(eventType), + // The event type doubles as a filterable tag, so a phone can be set to only + // wake for device_offline without the manager needing per-event channels. + tags: [eventType], + }; + + // Optional deep link back into the manager, when it knows its own address. + const clickBase = (cfg.click_url as string | undefined)?.trim().replace(/\/+$/, ''); + if (clickBase) { + body.click = ctx.deviceId != null ? `${clickBase}/devices/${ctx.deviceId}` : clickBase; + } + + await this.postJson(serverUrl, JSON.stringify(body), this.ntfyAuthHeaders(cfg)); + } + + /** + * Access token if present, otherwise basic auth. Infrastructure alerts should not + * require a world-writable topic, so both of ntfy's schemes are supported and the + * token is preferred — it can be scoped and revoked without changing a password. + */ + private ntfyAuthHeaders(cfg: Record): Record { + const token = (cfg.token as string | undefined)?.trim(); + if (token) return { Authorization: `Bearer ${token}` }; + + const username = (cfg.username as string | undefined)?.trim(); + const password = (cfg.password as string | undefined) ?? ''; + if (username) { + const encoded = Buffer.from(`${username}:${password}`).toString('base64'); + return { Authorization: `Basic ${encoded}` }; + } + return {}; + } + + /** + * ntfy priority runs 1 (min) to 5 (max); 4 and above bypasses a phone's + * do-not-disturb. Reserve that for things that mean something is down, so the + * channel stays worth being woken by. + */ + private ntfyPriority(eventType: string): number { + if (['device_offline', 'log_error', 'high_cpu', 'high_memory'].includes(eventType)) return 4; + if (['device_online', 'device_discovered'].includes(eventType)) return 2; + return 3; + } + // ── Helpers ───────────────────────────────────────────────────────────────── - private postJson(url: string, body: string): Promise { + private postJson(url: string, body: string, extraHeaders: Record = {}): Promise { return new Promise((resolve, reject) => { const parsed = new URL(url); const isHttps = parsed.protocol === 'https:'; @@ -344,6 +432,7 @@ export class AlertService { headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), + ...extraHeaders, }, }; diff --git a/backend/src/services/__tests__/AlertService.test.ts b/backend/src/services/__tests__/AlertService.test.ts index 9f347a4..9e2aaa0 100644 --- a/backend/src/services/__tests__/AlertService.test.ts +++ b/backend/src/services/__tests__/AlertService.test.ts @@ -148,3 +148,147 @@ describe('discordColor', () => { expect(discordColor('device_offline')).toBe(0xef4444); }); }); + +// ── ntfy (issue #93) ───────────────────────────────────────────────────────── + +type NtfyService = { + ntfyPriority(eventType: string): number; + ntfyAuthHeaders(cfg: Record): Record; + sendNtfy( + cfg: Record, + eventType: string, + message: string, + ctx: { deviceId?: number; deviceName?: string; details?: string } + ): Promise; + postJson(url: string, body: string, headers?: Record): Promise; +}; + +const asNtfy = (s: AlertService) => s as unknown as NtfyService; + +describe('ntfyPriority', () => { + const p = (et: string) => asNtfy(new AlertService()).ntfyPriority(et); + + it('raises priority above do-not-disturb only for things that are down', () => { + expect(p('device_offline')).toBe(4); + expect(p('log_error')).toBe(4); + expect(p('high_cpu')).toBe(4); + }); + + it('keeps recovery and discovery quiet', () => { + expect(p('device_online')).toBe(2); + expect(p('device_discovered')).toBe(2); + }); + + it('leaves everything else at the default', () => { + expect(p('cert_expiry')).toBe(3); + expect(p('config_drift')).toBe(3); + expect(p('something_new')).toBe(3); + }); +}); + +describe('ntfyAuthHeaders', () => { + const h = (cfg: Record) => asNtfy(new AlertService()).ntfyAuthHeaders(cfg); + + it('prefers an access token over basic auth', () => { + expect(h({ token: 'tk_abc', username: 'u', password: 'p' })) + .toEqual({ Authorization: 'Bearer tk_abc' }); + }); + + it('falls back to basic auth', () => { + expect(h({ username: 'alice', password: 'secret' })) + .toEqual({ Authorization: `Basic ${Buffer.from('alice:secret').toString('base64')}` }); + }); + + it('sends no auth for an unprotected topic', () => { + expect(h({})).toEqual({}); + }); + + it('ignores whitespace-only credentials rather than sending an empty bearer', () => { + expect(h({ token: ' ' })).toEqual({}); + }); +}); + +describe('sendNtfy', () => { + function capture() { + const service = new AlertService(); + const calls: { url: string; body: Record; headers?: Record }[] = []; + asNtfy(service).postJson = async (url, body, headers) => { + calls.push({ url, body: JSON.parse(body), headers }); + }; + return { service, calls }; + } + + it('posts to the public server by default with topic, title, priority and tag', async () => { + const { service, calls } = capture(); + await asNtfy(service).sendNtfy({ topic: 'mt' }, 'device_offline', 'sw1 is unreachable', {}); + + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe('https://ntfy.sh'); + expect(calls[0].body.topic).toBe('mt'); + expect(calls[0].body.priority).toBe(4); + expect(calls[0].body.tags).toEqual(['device_offline']); + expect(String(calls[0].body.title)).toContain('Device Offline'); + }); + + it('honours a self-hosted server and strips a trailing slash', async () => { + const { service, calls } = capture(); + await asNtfy(service).sendNtfy( + { topic: 'mt', server_url: 'https://ntfy.internal.example.com/' }, + 'device_online', 'back', {} + ); + expect(calls[0].url).toBe('https://ntfy.internal.example.com'); + }); + + it('includes the device name and details in the body', async () => { + const { service, calls } = capture(); + await asNtfy(service).sendNtfy({ topic: 'mt' }, 'log_error', 'errors seen', { + deviceName: 'sw1', details: 'login failure', + }); + const msg = String(calls[0].body.message); + expect(msg).toContain('errors seen'); + expect(msg).toContain('Device: sw1'); + expect(msg).toContain('login failure'); + }); + + it('deep-links to the device when a manager URL is configured', async () => { + const { service, calls } = capture(); + await asNtfy(service).sendNtfy( + { topic: 'mt', click_url: 'https://mgr.example.com/' }, + 'device_offline', 'down', { deviceId: 42 } + ); + expect(calls[0].body.click).toBe('https://mgr.example.com/devices/42'); + }); + + it('omits the click link entirely when no manager URL is set', async () => { + const { service, calls } = capture(); + await asNtfy(service).sendNtfy({ topic: 'mt' }, 'device_offline', 'down', { deviceId: 42 }); + expect(calls[0].body.click).toBeUndefined(); + }); + + it('rejects a missing topic rather than posting somewhere unintended', async () => { + const { service, calls } = capture(); + await expect(asNtfy(service).sendNtfy({}, 'device_offline', 'x', {})) + .rejects.toThrow(/missing topic/); + expect(calls).toHaveLength(0); + }); + + it('rejects a non-http server URL', async () => { + const { service } = capture(); + await expect( + asNtfy(service).sendNtfy({ topic: 'mt', server_url: 'file:///etc/passwd' }, 'device_offline', 'x', {}) + ).rejects.toThrow(/must be http/); + }); + + it('rejects an unparseable server URL', async () => { + const { service } = capture(); + await expect( + asNtfy(service).sendNtfy({ topic: 'mt', server_url: 'not a url' }, 'device_offline', 'x', {}) + ).rejects.toThrow(/invalid server_url/); + }); + + it('passes the auth header through to the request', async () => { + const { service, calls } = capture(); + await asNtfy(service).sendNtfy({ topic: 'mt', token: 'tk_1' }, 'device_offline', 'x', {}); + expect(calls[0].headers).toEqual({ Authorization: 'Bearer tk_1' }); + }); +}); diff --git a/frontend/package.json b/frontend/package.json index 00f4e1a..ecc494c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.20.1-beta", + "version": "0.21.0-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 5434b5c..8d2519f 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -1750,8 +1750,8 @@ export default function SettingsPage() {
@@ -1784,6 +1784,40 @@ export default function SettingsPage() { setCfg('chat_id', e.target.value)} placeholder="-100123456789" /> + ) : chForm.type === 'ntfy' ? ( + <> +
+ + setCfg('server_url', e.target.value)} placeholder="https://ntfy.sh" /> +

Leave blank for the public ntfy.sh, or point at your own instance.

+
+
+ + setCfg('topic', e.target.value)} placeholder="mikrotik-alerts" /> +
+
+ + setCfg('token', e.target.value)} placeholder="tk_..." /> +

+ Preferred over a username and password. Anyone who can publish to an unprotected topic can spoof your alerts. +

+
+
+
+ + setCfg('username', e.target.value)} placeholder="(optional)" /> +
+
+ + setCfg('password', e.target.value)} placeholder="••••••••" /> +
+
+
+ + setCfg('click_url', e.target.value)} placeholder="https://mikrotik.example.com" /> +

Makes each notification tappable, opening the device it refers to.

+
+ ) : ( <>
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 48a6309..a37dc51 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -825,7 +825,7 @@ export interface AlertRule { export interface AlertChannel { id: number; name: string; - type: 'email' | 'slack' | 'discord' | 'telegram'; + type: 'email' | 'slack' | 'discord' | 'telegram' | 'ntfy'; enabled: boolean; config: Record; created_at: string; diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 63a26e8..25f5481 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.20.1 Beta'; +export const APP_VERSION = 'v0.21.0 Beta';