129 lines
3.4 KiB
JavaScript
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);
|
|
}
|