feat(alerts): ntfy.sh notification channel (v0.21.0-beta)

Closes #93.

Routing push notifications through email ingestion was a workaround. ntfy is now
a first-class channel alongside Slack, Discord, Telegram and email, using the
same alert_channels row and the same dispatch switch.

Built to cover the whole plausible configuration space rather than waiting on the
reporter's answers, so no config migration is needed once they arrive:

  - server_url is operator-supplied and defaults to https://ntfy.sh, so a
    self-hosted instance works — the likely case for infrastructure alerting
  - both of ntfy's auth schemes: an access token (preferred; scopable and
    revocable) or username/password, with none required for an open topic
  - optional click_url deep-links each notification to the device it refers to

Published as JSON to the server root rather than POST /{topic} with header
metadata: the header form is ASCII-only and these titles carry per-event emoji.

Severity maps onto ntfy's priority scale so the channel stays worth being woken
by — 4 (breaks through do-not-disturb) for device_offline, log_error and
CPU/memory pressure; 2 for recoveries and discoveries; 3 for everything else.
The event type is attached as a tag so a client can be filtered to wake only for
chosen events without needing a channel per event.

Secrets follow the existing pattern: token and password are added to
SENSITIVE_KEYS, so they are masked on read and the mask placeholder is restored
to the stored value on write rather than overwriting it.

The type column is a CHECK constraint rather than a lookup table, so widening it
means dropping and recreating the constraint; done idempotently for existing
installs.

Note on trust: server_url lets an admin point the channel at an internal address.
That is the same exposure the existing Slack and Discord webhook URLs already
have, and only an admin can configure any of them. Restricting it would break the
self-hosted case this feature is mostly for.

Verified against the real ntfy.sh — a test alert arrived with the correct title,
emoji, body, priority, tag and click URL; masking verified to hide the token on
read while preserving it across a save that did not retype it. 13 unit tests
cover priority mapping, both auth schemes, URL normalisation, and rejection of
a missing topic or non-http server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-08-19 17:02:56 -07:00
co-authored by Claude Opus 5
parent b03ea87f11
commit 19617e8357
10 changed files with 296 additions and 12 deletions
+1 -1
View File
@@ -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": {
+7 -1
View File
@@ -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.
+3 -1
View File
@@ -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<string, string[]> = {
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<string, unknown>): Record<string, unknown> {
+91 -2
View File
@@ -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<string, unknown>;
}
@@ -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<string, unknown>,
eventType: string,
message: string,
ctx: AlertContext
): Promise<void> {
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<string, unknown> = {
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<string, unknown>): Record<string, string> {
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<void> {
private postJson(url: string, body: string, extraHeaders: Record<string, string> = {}): Promise<void> {
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,
},
};
@@ -148,3 +148,147 @@ describe('discordColor', () => {
expect(discordColor('device_offline')).toBe(0xef4444);
});
});
// ── ntfy (issue #93) ─────────────────────────────────────────────────────────
type NtfyService = {
ntfyPriority(eventType: string): number;
ntfyAuthHeaders(cfg: Record<string, unknown>): Record<string, string>;
sendNtfy(
cfg: Record<string, unknown>,
eventType: string,
message: string,
ctx: { deviceId?: number; deviceName?: string; details?: string }
): Promise<void>;
postJson(url: string, body: string, headers?: Record<string, string>): Promise<void>;
};
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<string, unknown>) => 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<string, unknown>; headers?: Record<string, string> }[] = [];
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' });
});
});