diff --git a/.env.example b/.env.example index f6d91c8..b0e169f 100644 --- a/.env.example +++ b/.env.example @@ -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 @@ -68,4 +68,3 @@ EMQX_API_SECRET= # Default number of days a client ban is active (default: 7). EMQX_BAN_DEFAULT_DAYS=7 - diff --git a/README.md b/README.md index 8ab89e0..84695a3 100644 --- a/README.md +++ b/README.md @@ -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`. @@ -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`. diff --git a/scripts/lib/mqtt-discord-format.js b/scripts/lib/mqtt-discord-format.js index 01740fe..479be6b 100644 --- a/scripts/lib/mqtt-discord-format.js +++ b/scripts/lib/mqtt-discord-format.js @@ -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 ``; +} + function compactRule(rule) { return formatProfileRuleAsEmqxSpec(rule); } @@ -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 }, ); } diff --git a/scripts/lib/mqtt-emqx-api.js b/scripts/lib/mqtt-emqx-api.js index 47057d8..e50e35b 100644 --- a/scripts/lib/mqtt-emqx-api.js +++ b/scripts/lib/mqtt-emqx-api.js @@ -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 }, + }; +} diff --git a/scripts/mqtt.js b/scripts/mqtt.js index 10a39fd..51a448e 100644 --- a/scripts/mqtt.js +++ b/scripts/mqtt.js @@ -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 { @@ -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) { @@ -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", }); } @@ -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", }); } diff --git a/test/mqtt-commands.test.js b/test/mqtt-commands.test.js index 2adae91..ae516fc 100644 --- a/test/mqtt-commands.test.js +++ b/test/mqtt-commands.test.js @@ -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", () => { @@ -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, //); + } finally { + global.fetch = previousFetch; + } }); test("mqtt.my-account DMs account details when invoked in a server", async () => { @@ -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, //); + } finally { + global.fetch = previousFetch; + } }); test("mqtt.whois allows admin checks from DM by fetching configured guild membership", async () => { diff --git a/test/mqtt-emqx-api.test.js b/test/mqtt-emqx-api.test.js index f90a359..d3d790d 100644 --- a/test/mqtt-emqx-api.test.js +++ b/test/mqtt-emqx-api.test.js @@ -1,7 +1,13 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { banClient, unbanClient, listBans, getDefaultBanDays } from "../scripts/lib/mqtt-emqx-api.js"; +import { + banClient, + unbanClient, + listBans, + listActiveClientsForUsername, + getDefaultBanDays, +} from "../scripts/lib/mqtt-emqx-api.js"; const EMQX_API_URL = "http://emqx:18083"; const EMQX_API_KEY = "testkey"; @@ -262,6 +268,42 @@ test("listBans returns empty data array on empty response", async () => { }); }); +test("listActiveClientsForUsername queries connected clients by username", async () => { + await withEmqxEnv(async () => { + const clients = [ + { + clientid: "MeshtasticAndroidMqttProxy-!deadbeef", + username: "jbouse", + connected: true, + connected_at: "2026-04-29T12:00:00.000+00:00", + }, + ]; + const meta = { count: 1, page: 1, limit: 10 }; + const fetchImpl = captureFetch(buildFetchOk(200, { data: clients, meta })); + + const result = await listActiveClientsForUsername({ username: "jbouse", fetchImpl }); + + assert.deepEqual(result.data, clients); + assert.deepEqual(result.meta, meta); + + const [call] = fetchImpl.calls; + assert.ok(call.url.includes("/api/v5/clients"), "URL should include /api/v5/clients"); + assert.ok(call.url.includes("username=jbouse"), "URL should include username filter"); + assert.ok(call.url.includes("conn_state=connected"), "URL should include connected-state filter"); + assert.ok(call.url.includes("limit=10"), "URL should include limit param"); + assert.equal(call.options.method, "GET"); + }); +}); + +test("listActiveClientsForUsername requires username", async () => { + await withEmqxEnv(async () => { + await assert.rejects( + () => listActiveClientsForUsername({ username: "", fetchImpl: buildFetchOk(200, {}) }), + /username is required/, + ); + }); +}); + test("banClient throws when EMQX_API_URL is not configured", async () => { const original = process.env.EMQX_API_URL; delete process.env.EMQX_API_URL;