Files
Qris-Soundbox/scripts/smoke-mqtt-acl.mjs

129 lines
3.4 KiB
JavaScript

#!/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=<device-a-id> \\
MQTT_TEST_DEVICE_A_PASSWORD=<secret> \\
MQTT_TEST_DEVICE_B_USERNAME=<device-b-id> \\
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);
}