Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ HUBOT_DISCORD_PERMISSION_GUILD_ID=
MQTT_ADMIN_GUILD_ID=
MQTT_ADMIN_ROLE_IDS=

# ─── EMQX API settings (for `hubot mqtt.ban ...`) ────────────────────────────
# ─── EMQX API settings (for `hubot mqtt ...`) ────────────────────────────────
# Base URL of the EMQX HTTP API (v5).
# Example: http://emqx:18083
EMQX_API_URL=http://emqx:18083
Expand All @@ -68,4 +68,3 @@ EMQX_API_SECRET=

# Default number of days a client ban is active (default: 7).
EMQX_BAN_DEFAULT_DAYS=7

9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ cp .env.example .env
| `MONGO_DB_NAME` | no | `mqtt` | MongoDB database name used by the `mqtt` account commands |
| `MQTT_ADMIN_GUILD_ID` | no | — | Discord guild ID used to verify admin role membership for DM commands |
| `MQTT_ADMIN_ROLE_IDS` | no | — | Comma-separated Discord role IDs allowed to run MQTT admin commands |
| `EMQX_API_URL` | no | — | EMQX HTTP API base URL used for MQTT ban commands and active client lookup |
| `EMQX_API_KEY` | no | — | EMQX API key used for HTTP Basic Auth |
| `EMQX_API_SECRET` | no | — | EMQX API secret used for HTTP Basic Auth |
| `EMQX_BAN_DEFAULT_DAYS` | no | `7` | Default number of days used by `mqtt.ban` |

> **Security note:** Never commit your `.env` file. It is listed in `.gitignore`.

Expand Down Expand Up @@ -290,10 +294,13 @@ Behavior:
intentionally fixed to lowercase letters, digits, underscores, and hyphens
with a leading lowercase letter.
- `mqtt.my-account` shows the caller's current MQTT username, status, profile,
and creation time.
active EMQX client connections when the EMQX API is configured, and creation
time.
- `mqtt.rotate` rotates the caller's password and sends the new password by DM.
- `mqtt.reset` resets another user's password and attempts to DM the new
password to the account owner.
- `mqtt.whois` shows admin account details, including active EMQX client
connections when the EMQX API is configured.
- `mqtt.reset`, `mqtt.whois`, `mqtt.disable`, `mqtt.enable`, and
`mqtt.profile.set` are admin-only and require the caller to hold a configured
Discord role from `MQTT_ADMIN_ROLE_IDS`.
Expand Down
56 changes: 51 additions & 5 deletions scripts/lib/mqtt-discord-format.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ function isoOrUnknown(value, fallback = "unknown") {
return value ? new Date(value).toISOString() : fallback;
}

function formatDiscordTimestamp(value) {
if (!value) {
return null;
}

const timestampMs = new Date(value).getTime();
if (!Number.isFinite(timestampMs)) {
return null;
}

return `<t:${Math.floor(timestampMs / 1000)}:f>`;
}

function compactRule(rule) {
return formatProfileRuleAsEmqxSpec(rule);
}
Expand All @@ -23,28 +36,61 @@ function formatDiscordOwner(user) {
return tag || "unknown";
}

export function buildMyAccountEmbed(user) {
function formatConnectionClient(client) {
const clientId = String(client?.clientid ?? "unknown").trim() || "unknown";
const connectedAt = formatDiscordTimestamp(client?.connected_at);
return connectedAt ? `${clientId} since ${connectedAt}` : clientId;
}

function formatActiveConnections(connections) {
if (!connections) {
return "unavailable";
}

const clients = Array.isArray(connections.data) ? connections.data : [];
const count = Number(connections.meta?.count ?? clients.length);
if (clients.length === 0 && count <= 0) {
return "none";
}

const visibleClients = clients.slice(0, 10).map(formatConnectionClient);
const summary = `${count} active`;
if (visibleClients.length === 0) {
return summary;
}

const remaining = count > visibleClients.length
? `\n...and ${count - visibleClients.length} more`
: "";
return `${summary}\n${visibleClients.join("\n")}${remaining}`;
}

export function buildMyAccountEmbed(user, connections = null) {
return new EmbedBuilder()
.setColor(MQTT_EMBED_COLOR)
.setTitle("MQTT Account")
.setTimestamp(new Date())
.addFields(
{ name: "Username", value: user.username ?? "unknown", inline: true },
{ name: "Status", value: user.status ?? "unknown", inline: true },
{ name: "Profile", value: user.profile ?? "unset", inline: true },
{ name: "Created", value: isoOrUnknown(user.created_at), inline: false },
{ name: "Active Connections", value: formatActiveConnections(connections), inline: false },
{ name: "Created", value: formatDiscordTimestamp(user.created_at) ?? isoOrUnknown(user.created_at), inline: false },
);
}

export function buildWhoisEmbed(user) {
export function buildWhoisEmbed(user, connections = null) {
return new EmbedBuilder()
.setColor(MQTT_EMBED_COLOR)
.setTitle(`MQTT Account: ${user.username ?? "unknown"}`)
.setTimestamp(new Date())
.addFields(
{ name: "Status", value: user.status ?? "unknown", inline: true },
{ name: "Profile", value: user.profile ?? "unset", inline: true },
{ name: "Owner", value: formatDiscordOwner(user), inline: false },
{ name: "Created", value: isoOrUnknown(user.created_at), inline: true },
{ name: "Updated", value: isoOrUnknown(user.updated_at, "never"), inline: true },
{ name: "Active Connections", value: formatActiveConnections(connections), inline: false },
{ name: "Created", value: formatDiscordTimestamp(user.created_at) ?? isoOrUnknown(user.created_at), inline: true },
{ name: "Updated", value: formatDiscordTimestamp(user.updated_at) ?? isoOrUnknown(user.updated_at, "never"), inline: true },
);
}

Expand Down
25 changes: 25 additions & 0 deletions scripts/lib/mqtt-emqx-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,28 @@ export async function listBans({ page = 1, limit = 20, fetchImpl } = {}) {
meta: json?.meta ?? { count: 0, page, limit },
};
}

export async function listActiveClientsForUsername({ username, limit = 10, fetchImpl } = {}) {
const normalizedUsername = String(username ?? "").trim();
if (!normalizedUsername) {
throw new Error("username is required");
}

const params = new URLSearchParams({
username: normalizedUsername,
conn_state: "connected",
page: "1",
limit: String(limit),
});

const json = await emqxRequest({
method: "GET",
path: `/api/v5/clients?${params.toString()}`,
fetchImpl,
});

return {
data: Array.isArray(json?.data) ? json.data : [],
meta: json?.meta ?? { count: 0, page: 1, limit },
};
}
37 changes: 34 additions & 3 deletions scripts/mqtt.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ import {
buildBanListEmbed,
summarizeCommandResult,
} from "./lib/mqtt-discord-format.js";
import { banClient, unbanClient, listBans, getDefaultBanDays } from "./lib/mqtt-emqx-api.js";
import {
banClient,
unbanClient,
listBans,
getDefaultBanDays,
listActiveClientsForUsername,
} from "./lib/mqtt-emqx-api.js";
import { verifyAdminGuildOnReady } from "./lib/mqtt-perms.js";
import { validateUsernamePolicy } from "./lib/mqtt-policy.js";
import {
Expand Down Expand Up @@ -99,6 +105,13 @@ async function ensureUsernameAvailable({ collections, username, discordUserId })
}
}

function isEmqxApiLookupConfigured() {
return Boolean(
String(process.env.EMQX_API_URL ?? "").trim()
&& String(process.env.EMQX_API_KEY ?? "").trim(),
);
}

async function createAccount({ robot, ctx, requestedUsername }) {
const { userId, discordTag } = getDiscordIdentity(ctx);
if (!userId) {
Expand Down Expand Up @@ -192,10 +205,19 @@ async function getMyAccount({ robot, ctx }) {
throw new Error("you do not have an MQTT account");
}

let connections = null;
if (isEmqxApiLookupConfigured()) {
try {
connections = await listActiveClientsForUsername({ username: user.username });
} catch (error) {
robot.logger.warn(`mqtt.my-account active connection lookup failed for ${user.username}: ${error.message}`);
}
}

return deliverEmbedPossiblyViaDm({
robot,
ctx,
embed: buildMyAccountEmbed(user),
embed: buildMyAccountEmbed(user, connections),
commandName: "mqtt.my-account",
});
}
Expand Down Expand Up @@ -251,10 +273,19 @@ async function getAccountWhois({ robot, ctx }) {
const collections = await getMqttCollections();
const user = await getUserByUsernameOrThrow(collections, ctx.args.username);

let connections = null;
if (isEmqxApiLookupConfigured()) {
try {
connections = await listActiveClientsForUsername({ username: user.username });
} catch (error) {
robot.logger.warn(`mqtt.whois active connection lookup failed for ${user.username}: ${error.message}`);
}
}

return deliverEmbedPossiblyViaDm({
robot,
ctx,
embed: buildWhoisEmbed(user),
embed: buildWhoisEmbed(user, connections),
commandName: "mqtt.whois",
});
}
Expand Down
104 changes: 104 additions & 0 deletions test/mqtt-commands.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,9 @@ test.afterEach(() => {
clearMqttCollectionsOverrideForTests();
delete process.env.MQTT_ADMIN_ROLE_IDS;
delete process.env.MQTT_ADMIN_GUILD_ID;
delete process.env.EMQX_API_URL;
delete process.env.EMQX_API_KEY;
delete process.env.EMQX_API_SECRET;
});

test("MQTT admin commands declare command-bus role permissions", () => {
Expand Down Expand Up @@ -384,6 +387,54 @@ test("mqtt.my-account returns an embed for an existing account", async () => {
const embed = reply.toJSON();
assert.equal(embed.title, "MQTT Account");
assert.equal(embed.fields.find((field) => field.name === "Username")?.value, "jbouse");
assert.ok(embed.timestamp);
});

test("mqtt.my-account includes active MQTT client connections", async () => {
const previousFetch = global.fetch;
process.env.EMQX_API_URL = "http://emqx:18083";
process.env.EMQX_API_KEY = "testkey";
process.env.EMQX_API_SECRET = "testsecret";
global.fetch = async (url, options) => ({
ok: true,
status: 200,
async json() {
return {
data: [
{
clientid: "MeshtasticAndroidMqttProxy-!deadbeef",
username: "jbouse",
connected: true,
connected_at: "2026-04-29T12:00:00.000+00:00",
},
],
meta: { count: 1, page: 1, limit: 10 },
};
},
});

try {
const { collections, commands } = setup();
collections.state.users.push({
_id: "1",
username: "jbouse",
profile: "default",
status: "active",
discord_user_id: "505598218306977793",
created_at: new Date("2026-04-19T00:00:00.000Z"),
});

const reply = await commands.get("mqtt.my-account").handler(createContext({ guildId: null }));

assert.ok(reply instanceof EmbedBuilder);
const embed = reply.toJSON();
const connections = embed.fields.find((field) => field.name === "Active Connections")?.value ?? "";
assert.match(connections, /1 active/);
assert.match(connections, /MeshtasticAndroidMqttProxy-!deadbeef/);
assert.match(connections, /<t:1777464000:f>/);
} finally {
global.fetch = previousFetch;
}
});

test("mqtt.my-account DMs account details when invoked in a server", async () => {
Expand Down Expand Up @@ -496,6 +547,59 @@ test("mqtt.whois DMs account details when invoked by an admin in a server", asyn
const embed = robot.dmMessages[0].embeds[0].toJSON();
assert.equal(embed.title, "MQTT Account: jbouse");
assert.equal(embed.fields.find((field) => field.name === "Owner")?.value, "<@600>");
assert.ok(embed.timestamp);
});

test("mqtt.whois includes active MQTT client connections for admins", async () => {
const previousFetch = global.fetch;
process.env.EMQX_API_URL = "http://emqx:18083";
process.env.EMQX_API_KEY = "testkey";
process.env.EMQX_API_SECRET = "testsecret";
global.fetch = async () => ({
ok: true,
status: 200,
async json() {
return {
data: [
{
clientid: "MeshtasticPythonMqttProxy-!cafebabe",
username: "jbouse",
connected: true,
connected_at: "2026-04-29T14:26:52.012+00:00",
},
],
meta: { count: 1, page: 1, limit: 10 },
};
},
});

try {
const { collections, commands } = setup();
collections.state.users.push({
_id: "1",
username: "jbouse",
profile: "default",
status: "active",
discord_user_id: "600",
discord_tag: "jbouse",
created_at: new Date("2026-04-19T00:00:00.000Z"),
});

const reply = await commands.get("mqtt.whois").handler(createContext({
args: { username: "jbouse" },
guildId: null,
roleIds: ["1"],
}));

assert.ok(reply instanceof EmbedBuilder);
const embed = reply.toJSON();
const connections = embed.fields.find((field) => field.name === "Active Connections")?.value ?? "";
assert.match(connections, /1 active/);
assert.match(connections, /MeshtasticPythonMqttProxy-!cafebabe/);
assert.match(connections, /<t:1777472812:f>/);
} finally {
global.fetch = previousFetch;
}
});

test("mqtt.whois allows admin checks from DM by fetching configured guild membership", async () => {
Expand Down
Loading
Loading