#!/usr/bin/env node import mqtt from "mqtt"; import "dotenv/config"; const brokerUrl = process.env.MQTT_BROKER_URL; const deviceA = { username: process.env.MQTT_TEST_DEVICE_A_USERNAME, password: process.env.MQTT_TEST_DEVICE_A_PASSWORD }; const deviceB = { username: process.env.MQTT_TEST_DEVICE_B_USERNAME, password: process.env.MQTT_TEST_DEVICE_B_PASSWORD || process.env.MQTT_TEST_DEVICE_A_PASSWORD }; const timeoutMs = Number(process.env.MQTT_ACL_SMOKE_TIMEOUT_MS || 8000); function usage() { console.log(`Usage: MQTT_BROKER_URL=mqtts://broker:8883 \\ MQTT_TEST_DEVICE_A_USERNAME= \\ MQTT_TEST_DEVICE_A_PASSWORD= \\ MQTT_TEST_DEVICE_B_USERNAME= \\ npm run smoke:mqtt-acl This validates Mosquitto ACL behavior from a device credential perspective: - device A can subscribe/read devices/A/downlink/# - device A can publish/write devices/A/uplink/# - device A is denied subscribe/read to devices/B/downlink/# `); } if (!brokerUrl || !deviceA.username || !deviceA.password || !deviceB.username) { usage(); process.exit(1); } function connectDevice(device) { const client = mqtt.connect(brokerUrl, { username: device.username, password: device.password, clientId: `qris-acl-smoke-${device.username}-${Date.now()}`, reconnectPeriod: 0, connectTimeout: timeoutMs }); return new Promise((resolve, reject) => { const timer = setTimeout(() => { client.end(true); reject(new Error(`connect timeout for ${device.username}`)); }, timeoutMs); client.once("connect", () => { clearTimeout(timer); resolve(client); }); client.once("error", (error) => { clearTimeout(timer); client.end(true); reject(error); }); }); } function subscribe(client, topic) { return new Promise((resolve, reject) => { client.subscribe(topic, { qos: 1 }, (error, grants) => { if (error) { reject(error); return; } resolve(grants || []); }); }); } function publish(client, topic, payload) { return new Promise((resolve, reject) => { client.publish(topic, payload, { qos: 1 }, (error) => { if (error) { reject(error); return; } resolve(); }); }); } function granted(grants) { return grants.some((grant) => Number(grant.qos) !== 128); } const client = await connectDevice(deviceA); try { const ownDownlink = `devices/${deviceA.username}/downlink/#`; const ownUplink = `devices/${deviceA.username}/uplink/acl-smoke`; const foreignDownlink = `devices/${deviceB.username}/downlink/#`; const ownGrants = await subscribe(client, ownDownlink); if (!granted(ownGrants)) { throw new Error(`device ${deviceA.username} could not subscribe own downlink topic`); } await publish( client, ownUplink, JSON.stringify({ type: "acl_smoke", device_id: deviceA.username, timestamp: new Date().toISOString() }) ); const foreignGrants = await subscribe(client, foreignDownlink); if (granted(foreignGrants)) { throw new Error(`ACL allows ${deviceA.username} to subscribe foreign downlink ${foreignDownlink}`); } console.log( JSON.stringify( { ok: true, broker_url: brokerUrl, device: deviceA.username, own_downlink: ownDownlink, own_uplink: ownUplink, foreign_downlink_denied: foreignDownlink }, null, 2 ) ); } finally { client.end(true); }