diff --git a/docs/MQTT_SETUP.md b/docs/MQTT_SETUP.md index 51fbaac..6a64c3f 100644 --- a/docs/MQTT_SETUP.md +++ b/docs/MQTT_SETUP.md @@ -1,47 +1,62 @@ # EMQX + Home Assistant setup -Broker for this deployment: **EMQX 5.x at 192.168.1.31**. +Broker for this deployment: **EMQX 6.0.1 at 192.168.1.31**. -## 0. Starting state (probed 2026-08-14) +Note that EMQX 6.x still serves its management API under `/api/v5` for +backward compatibility — the API path is not a version indicator. -| Check | Result | -|---|---| -| Listeners 1883 / 8883 / 8083 / 8084 / 18083 | all open | -| Anonymous MQTT connect | **accepted** | -| Connect with a made-up username/password | **accepted** | -| Retained message publish + replay to a new client | works | -| Existing retained topics on the broker | none — broker is empty | -| Management API (`/api/v5/clients` etc.) | protected, HTTP 401 | -| Public `/api/v5/status` | 200 (normal, by design) | +## 0. Broker state -**The MQTT data plane has no authenticator configured.** Any client on the LAN -can connect, subscribe to `#`, and publish anywhere — including publishing to -`homeassistant/...` discovery topics, which means anything on the network can -create or overwrite entities in Home Assistant. The dashboard itself is fine; -it's only the broker side that is open. +Probed 2026-08-14, before and after the authenticator was created: -Until step 1 is done, the `MQTT_USER` / `MQTT_PASSWORD` values in `secrets.h` -are decorative — EMQX accepts them without checking, so a working node proves -nothing about whether the credentials are right. +| Check | Before | After | +|---|---|---| +| Listeners 1883 / 8883 / 8083 / 8084 / 18083 | all open | all open | +| Anonymous MQTT connect | **accepted** | refused (bad username or password) | +| Connect with a made-up username/password | **accepted** | refused (not authorized) | +| Connect as `soil-node` from `secrets.h` | n/a | accepted | +| Publish to `soil/#` | allowed | allowed | +| Publish to `homeassistant/sensor/#` | allowed | allowed | +| Retained publish + replay to a new client | works | works | +| Management API (`/api/v5/clients`) | protected, HTTP 401 | protected | +| Public `/api/v5/status` | 200 (normal, by design) | 200 | -## 1. Enable authentication in EMQX +The "before" column is what an EMQX install looks like with **no authenticator +configured**: every client on the LAN can connect, subscribe to `#`, and +publish anywhere — including to `homeassistant/...` discovery topics, meaning +anything on the network could create or overwrite entities in Home Assistant. + +In that state the `MQTT_USER` / `MQTT_PASSWORD` values in `secrets.h` are +decorative: EMQX accepts them without checking, so a node connecting +successfully proves nothing about whether the credentials are correct. + +Re-run the check at any time: + +```bash +python3 tools/check_broker.py +``` + +## 1. Enable authentication in EMQX ✅ done Dashboard → **http://192.168.1.31:18083** -1. **Access Control → Authentication → Create** -2. Mechanism **Password-Based**, backend **Built-in Database** +1. **Access Control → Authentication**, then **Create** (top right) +2. Mechanism **Password-Based**, Backend **Built-in Database** → Next 3. Configure: - - User ID Type: `username` - - Password Hash: `bcrypt` (salt rounds 10) + - **UserID Type**: `username` (matches the CONNECT packet's Username field, + which is what `MQTT_USER` in `secrets.h` sets) + - **Password Hash**: `bcrypt` + - **Salt Rounds**: 10 (valid range 5–10; higher is better) 4. Create. The moment an authenticator exists, EMQX stops accepting anonymous -connections — so create the users in step 2 before flashing anything, and -expect Home Assistant to drop off the broker until you give it credentials too. +connections. Existing sessions stay up until they next reconnect, so there is +a grace period — but create the accounts in step 2 immediately, and expect +Home Assistant to fall off the broker until you give it credentials too. ## 2. Create one account per client -Under the authenticator you just made → **Users → Add**: +Click the authenticator row you just created, then the **Users** tab → **Add**: | Username | Used by | |---|---| diff --git a/tools/check_broker.py b/tools/check_broker.py new file mode 100644 index 0000000..d2ffd53 --- /dev/null +++ b/tools/check_broker.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +"""Verify the broker is configured the way the firmware expects. + +Reads include/secrets.h by path and uses the values in place. It never prints +the password, and never echoes it to argv or the environment -- run it freely, +the output is safe to paste into a chat or an issue. + + python3 tools/check_broker.py + +Exit status is 0 only if every check that matters passes. +""" + +import argparse +import os +import re +import sys +import time + +try: + import paho.mqtt.client as mqtt +except ImportError: + sys.exit("paho-mqtt not installed: pip install paho-mqtt") + +# paho 2.x normalises MQTT 3.1.1 CONNACK codes onto the MQTT 5 reason-code +# numbering, so both ranges can show up depending on callback API version. +CONNACK = { + # MQTT 3.1.1 + 0: "accepted", + 1: "refused: unacceptable protocol version", + 2: "refused: client id rejected", + 3: "refused: server unavailable", + 4: "refused: bad username or password", + 5: "refused: not authorized", + # MQTT 5 + 128: "refused: unspecified error", + 132: "refused: unsupported protocol version", + 133: "refused: client identifier not valid", + 134: "refused: bad username or password", + 135: "refused: not authorized", + 136: "refused: server unavailable", + 137: "refused: server busy", + 138: "refused: banned", + 151: "refused: quota exceeded", + 159: "refused: connection rate exceeded", +} + +HERE = os.path.dirname(os.path.abspath(__file__)) +DEFAULT_SECRETS = os.path.join(HERE, os.pardir, "include", "secrets.h") + + +def parse_secrets(path): + """Pull the MQTT settings out of secrets.h without echoing them.""" + if not os.path.exists(path): + sys.exit("no secrets.h at %s -- copy include/secrets.h.example first" % path) + text = open(path).read() + + def macro(name): + m = re.search(r'^\s*#define\s+%s\s+"([^"]*)"' % name, text, re.M) + return m.group(1) if m else None + + def macro_int(name): + m = re.search(r"^\s*#define\s+%s\s+(\d+)" % name, text, re.M) + return int(m.group(1)) if m else None + + cfg = { + "host": macro("MQTT_HOST"), + "port": macro_int("MQTT_PORT") or 1883, + "user": macro("MQTT_USER"), + "password": macro("MQTT_PASSWORD"), + } + if not cfg["host"]: + sys.exit("MQTT_HOST not found in %s" % path) + return cfg + + +def rc_to_int(rc): + """paho 2.x hands the v2 callback a ReasonCode; paho 1.x hands it an int.""" + return int(getattr(rc, "value", rc)) + + +def try_connect(host, port, user=None, password=None, client_id="broker-check"): + """Return the CONNACK code, or a string describing a transport failure.""" + result = {} + + def on_connect(client, userdata, flags, rc, properties=None): + result["rc"] = rc_to_int(rc) + + c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=client_id) + if user: + c.username_pw_set(user, password) + c.on_connect = on_connect + try: + c.connect(host, port, 10) + c.loop_start() + for _ in range(80): + if "rc" in result: + break + time.sleep(0.1) + c.loop_stop() + c.disconnect() + except Exception as exc: # noqa: BLE001 - surfacing the transport error is the point + return "transport error: %s" % exc + return result.get("rc", "no CONNACK within 8s") + + +def check_publish(cfg, topic, retain=False): + """True if a publish to `topic` is accepted and echoed back to us.""" + got = [] + c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="broker-check-pub") + if cfg["user"]: + c.username_pw_set(cfg["user"], cfg["password"]) + c.on_message = lambda cl, u, m: got.append(m.payload) + try: + c.connect(cfg["host"], cfg["port"], 10) + c.loop_start() + c.subscribe(topic, qos=0) + time.sleep(0.4) + c.publish(topic, "check", qos=0, retain=retain) + time.sleep(1.2) + if retain: + # Leave nothing behind: an empty retained payload clears it. + c.publish(topic, "", qos=0, retain=True) + time.sleep(0.6) + c.loop_stop() + c.disconnect() + except Exception: # noqa: BLE001 + return False + return bool(got) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--secrets", default=DEFAULT_SECRETS) + args = ap.parse_args() + + cfg = parse_secrets(args.secrets) + print("broker : %s:%d" % (cfg["host"], cfg["port"])) + print("username : %s" % (cfg["user"] or "")) + print("password : %s\n" % ("" % len(cfg["password"]) if cfg["password"] else "")) + + failures = [] + warnings = [] + + # 1. Anonymous must be refused once an authenticator exists. + rc = try_connect(cfg["host"], cfg["port"], client_id="broker-check-anon") + if not isinstance(rc, int): + # Never treat "couldn't tell" as "secure" -- that is how an open broker + # gets mistaken for a locked-down one. + print("[?] anonymous connect : INCONCLUSIVE (%s)" % rc) + failures.append("Could not determine whether anonymous access is allowed: %s" % rc) + elif rc == 0: + print("[!] anonymous connect : ACCEPTED <-- broker is still open") + warnings.append( + "Anonymous connections are still accepted. No authenticator is " + "configured, so the credentials below are not actually being checked." + ) + else: + print("[ok] anonymous connect : %s" % CONNACK.get(rc, rc)) + + # 2. Made-up credentials must be refused. + rc = try_connect(cfg["host"], cfg["port"], "not-a-real-user", "not-a-real-password", + client_id="broker-check-bogus") + if not isinstance(rc, int): + print("[?] bogus credentials : INCONCLUSIVE (%s)" % rc) + elif rc == 0: + print("[!] bogus credentials : ACCEPTED <-- not being verified") + else: + print("[ok] bogus credentials : %s" % CONNACK.get(rc, rc)) + + # 3. The real credentials must be accepted. + if not cfg["user"] or "CHANGEME" in (cfg["user"] or ""): + print("[!] configured credentials: MQTT_USER still unset/CHANGEME") + failures.append("Fill in MQTT_USER and MQTT_PASSWORD in secrets.h.") + else: + rc = try_connect(cfg["host"], cfg["port"], cfg["user"], cfg["password"], + client_id="broker-check-real") + label = CONNACK.get(rc, rc) if isinstance(rc, int) else rc + if rc == 0: + print("[ok] configured credentials: accepted") + elif not isinstance(rc, int): + print("[?] configured credentials: INCONCLUSIVE (%s)" % rc) + failures.append("Could not test the configured account: %s" % rc) + else: + print("[!] configured credentials: %s" % label) + failures.append( + "The account in secrets.h was rejected (%s). It must match the " + "user you created in EMQX exactly." % label + ) + + # 4. ACLs must permit the two topic trees the firmware publishes to. + for topic, why in ( + ("soil/_check/probe", "state payloads"), + ("homeassistant/sensor/_check/config", "HA discovery configs"), + ): + ok = check_publish(cfg, topic, retain=True) + print("[%s] publish %-38s (%s)" % ("ok" if ok else "!", topic, why)) + if not ok: + failures.append( + "Publishing to %s was blocked. Check the authorization rules " + "for user %r -- without this, %s never reach the broker." + % (topic, cfg["user"], why) + ) + + print() + for w in warnings: + print("WARNING: %s" % w) + for f in failures: + print("FAIL: %s" % f) + if not failures and not warnings: + print("All checks passed. The broker is ready for the node.") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main())