MQTT notification channel - publish events to a broker

This commit is contained in:
Jarek Krochmalski committed 2026-08-23 11:00:36 +02:00
1 parent bc73e7ba84
commit 9a7ef9c12c
6 files changed
+278 -1

No files matched your search

+4
View File
@@ -41,6 +41,7 @@ import { sendBark } from './bark';
import { sendSignal } from './signal';
import { sendApprise } from './apprise';
import { sendPushover } from './pushover';
import { sendMqtt } from './mqtt';
import { sendGenericWebhook } from './generic-webhook';
import { sendWorkflows } from './workflows';
import { sendZabbix } from './zabbix';
@@ -110,6 +111,9 @@ async function sendToAppriseUrl(url: string, payload: NotificationPayload): Prom
case 'pushover':
case 'pover':
return await sendPushover(url, payload);
case 'mqtt':
case 'mqtts':
return await sendMqtt(url, payload);
case 'json':
case 'jsons':
return await sendGenericWebhook(url, payload);
+82
View File
@@ -0,0 +1,82 @@
/**
* Pure MQTT URL parsing + message building for the MQTT notification channel.
* No `mqtt` import and no I/O, so the URL grammar (creds, ports, topic, qos/retain)
* is unit-testable without pulling the broker library. sendMqtt (mqtt.ts) consumes this.
*/
import { dangerousHostReason } from '../url-safety';
import type { NotificationPayload } from './shared';
import { titleWithEnv } from './shared';
export interface MqttTarget {
brokerUrl: string; // mqtt:// or mqtts:// scheme+host+port only, for mqtt.connect
host: string;
topic: string;
qos: 0 | 1 | 2;
retain: boolean;
username?: string;
password?: string;
}
/**
* Parse an Apprise-style mqtt(s):// URL into a connect target. Returns a reason
* string on invalid input instead of throwing.
* mqtt://host/topic, mqtt://user:pass@host:port/a/b, mqtts://host/topic
* ?qos=0|1|2 (default 0), ?retain=true|false (default false)
*/
export function parseMqttUrl(raw: string): { ok: true; target: MqttTarget } | { ok: false; reason: string } {
let u: URL;
try {
u = new URL(raw);
} catch {
return { ok: false, reason: 'not a valid URL' };
}
if (u.protocol !== 'mqtt:' && u.protocol !== 'mqtts:') {
return { ok: false, reason: `scheme ${u.protocol} not allowed (use mqtt or mqtts)` };
}
const secure = u.protocol === 'mqtts:';
const host = u.hostname.toLowerCase().replace(/^\[|\]$/g, '');
if (!host) return { ok: false, reason: 'missing broker host' };
if (host === 'localhost' || host.endsWith('.localhost')) return { ok: false, reason: 'localhost blocked' };
const hostReason = dangerousHostReason(host);
if (hostReason) return { ok: false, reason: hostReason };
const port = u.port ? Number(u.port) : secure ? 8883 : 1883;
if (!Number.isInteger(port) || port < 1 || port > 65535) {
return { ok: false, reason: `invalid port ${u.port}` };
}
const topic = decodeURIComponent(u.pathname.replace(/^\/+/, ''));
if (!topic) return { ok: false, reason: 'missing topic (add a path, e.g. mqtt://host/dockhand)' };
// A PUBLISH topic must not contain subscription wildcards.
if (topic.includes('+') || topic.includes('#')) {
return { ok: false, reason: 'publish topic must not contain wildcards (+ or #)' };
}
const qosRaw = u.searchParams.get('qos');
let qos: 0 | 1 | 2 = 0;
if (qosRaw !== null) {
if (qosRaw === '0' || qosRaw === '1' || qosRaw === '2') qos = Number(qosRaw) as 0 | 1 | 2;
else return { ok: false, reason: `invalid qos ${qosRaw} (use 0, 1 or 2)` };
}
const retain = u.searchParams.get('retain') === 'true';
const username = u.username ? decodeURIComponent(u.username) : undefined;
const password = u.password ? decodeURIComponent(u.password) : undefined;
const brokerUrl = `${secure ? 'mqtts' : 'mqtt'}://${u.hostname}:${port}`;
return { ok: true, target: { brokerUrl, host, topic, qos, retain, username, password } };
}
/** Build the JSON message body published to the topic. Timestamp is injected by the caller. */
export function buildMqttMessage(payload: NotificationPayload, timestamp: string): string {
return JSON.stringify({
title: titleWithEnv(payload),
message: payload.message,
type: payload.type ?? 'info',
eventType: payload.eventType,
environmentId: payload.environmentId,
environmentName: payload.environmentName,
timestamp,
});
}
+72
View File
@@ -0,0 +1,72 @@
/**
* MQTT notification channel. Publishes each event as a JSON message to a broker
* topic, so anything (Home Assistant, automations, other services) can subscribe.
*
* Apprise-style URL (parsed by mqtt-core.ts, unit-tested without a broker):
* mqtt://host/topic, mqtt://user:pass@host:port/a/b, mqtts://host/topic
* ?qos=0|1|2 (default 0), ?retain=true|false (default false)
*/
import mqtt from 'mqtt';
import type { NotificationPayload, NotificationResult } from './shared';
import { parseMqttUrl, buildMqttMessage } from './mqtt-core';
/** Timeout for the connect+publish round-trip, overridable like the HTTP channels. */
const MQTT_TIMEOUT_MS = (() => {
const parsed = Number(process.env.NOTIFICATION_TIMEOUT_MS);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 10_000;
})();
/** Publish a notification to an MQTT broker. Connects, publishes once, disconnects. */
export async function sendMqtt(url: string, payload: NotificationPayload): Promise<NotificationResult> {
const parsed = parseMqttUrl(url);
if (!parsed.ok) return { success: false, error: `Invalid MQTT URL: ${parsed.reason}` };
const t = parsed.target;
return new Promise<NotificationResult>((resolve) => {
let settled = false;
// One overall watchdog that is NEVER cleared until the promise resolves, so no
// path (connect, publish, or a stalled teardown) can hang past the timeout. If a
// graceful close wedges after the publish succeeded, the watchdog force-ends and
// still resolves success - the message was already PUBACK'd.
let pendingSuccess = false;
const overall = setTimeout(() => {
try { client.end(true); } catch { /* already closing */ }
finish(pendingSuccess ? { success: true } : { success: false, error: 'MQTT publish timed out' });
}, MQTT_TIMEOUT_MS);
const finish = (r: NotificationResult) => {
if (settled) return;
settled = true;
clearTimeout(overall);
resolve(r);
};
const client = mqtt.connect(t.brokerUrl, {
username: t.username,
password: t.password,
connectTimeout: MQTT_TIMEOUT_MS,
reconnectPeriod: 0, // one-shot: never auto-reconnect for a single publish
});
client.on('connect', () => {
const body = buildMqttMessage(payload, new Date().toISOString());
client.publish(t.topic, body, { qos: t.qos, retain: t.retain }, (err) => {
if (err) {
try { client.end(true); } catch { /* already closing */ }
finish({ success: false, error: `MQTT publish failed: ${err.message}` });
return;
}
// Publish OK. Close GRACEFULLY (DISCONNECT) so the broker fully processes it -
// a forced end() right after the PUBACK can drop a retained message. The
// overall watchdog still guards a teardown that never completes.
pendingSuccess = true;
try { client.end(false, {}, () => finish({ success: true })); }
catch { finish({ success: true }); }
});
});
client.on('error', (err) => {
try { client.end(true); } catch { /* already closing */ }
finish({ success: false, error: `MQTT connection failed: ${err.message}` });
});
});
}
@@ -451,6 +451,8 @@ ntfys://host/topic?auth=base64token
pushover://user_key/api_token
pushover://user_key/api_token/device1/device2
pover://user_key@api_token/device1/device2
mqtt://user:pass@broker-host:1883/dockhand/events?qos=1&amp;retain=true
mqtts://broker-host:8883/dockhand/events
workflows://hostname/workflow/signature
bark://bark_key
bark://host/bark_key
@@ -463,7 +465,7 @@ zabbixs://hostname/api_jsonrpc.php?token=TOKEN&amp;host=HOST&amp;key=ITEM_KEY"
class="flex min-h-[220px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
></textarea>
<p class="text-xs text-muted-foreground">
Built-in channels: Discord, Slack, Mattermost, Telegram, ntfy, Gotify, Pushover, Bark, Signal (via signal-cli-rest-api), Microsoft Teams (via Workflows), Zabbix (via history.push), and generic JSON.
Built-in channels: Discord, Slack, Mattermost, Telegram, ntfy, Gotify, Pushover, MQTT, Bark, Signal (via signal-cli-rest-api), Microsoft Teams (via Workflows), Zabbix (via history.push), and generic JSON.
</p>
<p class="flex gap-1.5 text-xs text-muted-foreground">
<HelpCircle class="w-3.5 h-3.5 shrink-0 mt-0.5 text-amber-500" />