#!/usr/bin/env python3 """ MQTT server (port 1883). Parses MQTT CONNECT packets, extracts client_id, etc. Responds with CONNACK. Supports dynamic topics and retained publishes. Logs PUBLISH commands sent by clients. """ import asyncio import json import os import random import struct from typing import cast import instance_seed as _seed from syslog_bridge import ( encode_secret, forward_syslog, syslog_line, write_syslog_file, ) NODE_NAME = os.environ.get("NODE_NAME", "mqtt-broker") SERVICE_NAME = "mqtt" LOG_TARGET = os.environ.get("LOG_TARGET", "") PORT = int(os.environ.get("PORT", "1883")) # Default to auth-required. A broker that accepts literally anyone with any # client_id / username was realistic for devices on a flat OT LAN pre-2018, # but in 2024+ it's a tell. Operators who *want* the anonymous-broker decoy # still set MQTT_ACCEPT_ALL=1 explicitly. MQTT_ACCEPT_ALL = os.environ.get("MQTT_ACCEPT_ALL", "0") == "1" # Optional cred list (user:pass comma-separated). If set, only those combos # succeed even when ACCEPT_ALL is off — lets operators plant credential bait. _MQTT_CREDS: set[tuple[str, str]] = set() for combo in os.environ.get("MQTT_CREDS", "").split(","): combo = combo.strip() if ":" in combo: u, _, p = combo.partition(":") _MQTT_CREDS.add((u, p)) _PERSONA_CHOICES = ["water_plant", "building_hvac", "solar_farm", "factory_line"] MQTT_PERSONA = os.environ.get("MQTT_PERSONA") or _seed.pick(_PERSONA_CHOICES) MQTT_CUSTOM_TOPICS = os.environ.get("MQTT_CUSTOM_TOPICS", "") # Fleet-diverse broker ID. Real mosquitto versions in the wild right now. _BROKER_VERSION = os.environ.get("MQTT_BROKER_VERSION") or _seed.pick([ "mosquitto version 1.6.9", "mosquitto version 2.0.11", "mosquitto version 2.0.15", "mosquitto version 2.0.17", "mosquitto version 2.0.18", "HiveMQ CE 2024.4", "EMQX 5.3.2", ]) _CONNACK_ACCEPTED = b"\x20\x02\x00\x00" _CONNACK_NOT_AUTH = b"\x20\x02\x00\x05" def _log(event_type: str, severity: int = 6, **kwargs) -> None: line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) write_syslog_file(line) forward_syslog(line, LOG_TARGET) def _read_utf8(data: bytes, pos: int): """Read MQTT UTF-8 string (2-byte length prefix). Returns (string, next_pos).""" if pos + 2 > len(data): return "", pos length = struct.unpack(">H", data[pos:pos + 2])[0] pos += 2 return data[pos:pos + length].decode(errors="replace"), pos + length def _parse_connect(payload: bytes): pos = 0 proto_name, pos = _read_utf8(payload, pos) if pos >= len(payload): return {}, pos _proto_level = payload[pos] pos += 1 if pos >= len(payload): return {}, pos flags = payload[pos] pos += 1 pos += 2 # Keep alive client_id, pos = _read_utf8(payload, pos) result = {"client_id": client_id, "proto": proto_name} if flags & 0x04: _, pos = _read_utf8(payload, pos) _, pos = _read_utf8(payload, pos) if flags & 0x80: username, pos = _read_utf8(payload, pos) result["username"] = username if flags & 0x40: password, pos = _read_utf8(payload, pos) result["password"] = password return result def _parse_subscribe(payload: bytes): """Returns (packet_id, [(topic, qos), ...])""" if len(payload) < 2: return 0, [] pos = 0 packet_id = struct.unpack(">H", payload[pos:pos+2])[0] pos += 2 topics = [] while pos < len(payload): topic, pos = _read_utf8(payload, pos) if pos >= len(payload): break qos = payload[pos] & 0x03 pos += 1 topics.append((topic, qos)) return packet_id, topics def _suback(packet_id: int, granted_qos: list[int]) -> bytes: payload = struct.pack(">H", packet_id) + bytes(granted_qos) return bytes([0x90, len(payload)]) + payload def _publish(topic: str, value: str, retain: bool = True) -> bytes: topic_bytes = topic.encode() topic_len = struct.pack(">H", len(topic_bytes)) payload = str(value).encode() fixed = 0x31 if retain else 0x30 remaining = len(topic_len) + len(topic_bytes) + len(payload) # variable length encoding rem_bytes = [] while remaining > 0: encoded = remaining % 128 remaining = remaining // 128 if remaining > 0: encoded = encoded | 128 rem_bytes.append(encoded) if not rem_bytes: rem_bytes = [0] return bytes([fixed]) + bytes(rem_bytes) + topic_len + topic_bytes + payload def _parse_publish(payload: bytes, qos: int): pos = 0 topic, pos = _read_utf8(payload, pos) packet_id = 0 if qos > 0: if pos + 2 <= len(payload): packet_id = struct.unpack(">H", payload[pos:pos+2])[0] pos += 2 data = payload[pos:] return topic, packet_id, data def _generate_topics() -> dict: topics: dict = {} if MQTT_CUSTOM_TOPICS: try: topics = json.loads(MQTT_CUSTOM_TOPICS) return topics except Exception as e: _log("config_error", severity=4, error=str(e)) if MQTT_PERSONA == "water_plant": site = _seed.pick(["north", "south", "east", "west", "plant-a", "plant-b"]) topics.update({ f"{site}/water/tank1/level": f"{random.uniform(60.0, 80.0):.1f}", f"{site}/water/tank1/pressure": f"{random.uniform(2.5, 3.0):.2f}", f"{site}/water/pump1/status": "RUNNING", f"{site}/water/pump1/rpm": f"{int(random.uniform(1400, 1450))}", f"{site}/water/pump2/status": "STANDBY", f"{site}/water/chlorine/dosing": f"{random.uniform(1.1, 1.3):.1f}", f"{site}/water/chlorine/residual": f"{random.uniform(0.7, 0.9):.1f}", f"{site}/water/valve/inlet/state": "OPEN", f"{site}/water/valve/drain/state": "CLOSED", f"{site}/alarm/high_pressure": "0", f"{site}/alarm/low_chlorine": "0", f"{site}/alarm/pump_fault": "0", }) elif MQTT_PERSONA == "building_hvac": floor = _seed.rng.randint(1, 12) for i in range(_seed.rng.randint(4, 10)): topics[f"bldg/floor{floor}/zone{i}/temp"] = f"{random.uniform(20.0, 24.5):.1f}" topics[f"bldg/floor{floor}/zone{i}/setpoint"] = f"{random.uniform(21.0, 23.0):.1f}" topics[f"bldg/floor{floor}/ahu/status"] = _seed.pick(["RUNNING", "RUNNING", "IDLE"]) elif MQTT_PERSONA == "solar_farm": for arr in range(1, _seed.rng.randint(4, 9)): topics[f"solar/array{arr}/power_kw"] = f"{random.uniform(40.0, 180.0):.1f}" topics[f"solar/array{arr}/irradiance"] = f"{random.uniform(500, 950):.0f}" elif MQTT_PERSONA == "factory_line": line = _seed.pick(["A", "B", "C"]) for m in range(1, _seed.rng.randint(3, 7)): topics[f"line{line}/machine{m}/state"] = _seed.pick(["RUN", "RUN", "IDLE", "FAULT"]) topics[f"line{line}/machine{m}/cycle_count"] = str(_seed.rng.randint(1000, 999_999)) if not topics: topics = { "device/status": "online", "device/uptime": str(_seed.uptime_seconds()), } # $SYS keys match every real broker. topics["$SYS/broker/version"] = _BROKER_VERSION topics["$SYS/broker/uptime"] = f"{_seed.uptime_seconds()} seconds" topics["$SYS/broker/clients/connected"] = str(_seed.rng.randint(2, 24)) return topics class MQTTProtocol(asyncio.Protocol): _transport: asyncio.Transport | None = None _peer: tuple[str, int] | None = None def __init__(self): self._transport = None self._peer = None self._buf = b"" self._auth = False self._topics = _generate_topics() def connection_made(self, transport: asyncio.BaseTransport) -> None: self._transport = cast(asyncio.Transport, transport) self._peer = cast(tuple[str, int], self._transport.get_extra_info("peername", ("?", 0))) _log("connect", src=self._peer[0], src_port=self._peer[1]) def data_received(self, data): self._buf += data try: self._process() except Exception as e: _log("protocol_error", severity=4, error=str(e)) if self._transport: self._transport.close() def _process(self): assert self._transport is not None assert self._peer is not None while len(self._buf) >= 2: pkt_byte = self._buf[0] pkt_type = (pkt_byte >> 4) & 0x0f flags = pkt_byte & 0x0f qos = (flags >> 1) & 0x03 # Decode remaining length (variable-length encoding) pos = 1 remaining = 0 multiplier = 1 while pos < len(self._buf): if pos > 4: # MQTT spec: max 4 bytes for remaining length self._transport.close() self._buf = b"" return byte = self._buf[pos] remaining += (byte & 0x7f) * multiplier multiplier *= 128 pos += 1 if not (byte & 0x80): break else: return # incomplete length if len(self._buf) < pos + remaining: return # incomplete payload payload = self._buf[pos:pos + remaining] self._buf = self._buf[pos + remaining:] if pkt_type == 1: # CONNECT info = _parse_connect(payload) # Migrate auth event to the universal credential SD shape # so the ingester's native branch picks up the row. The # legacy username/password keys are intentionally NOT # forwarded — encode_secret() supplies secret_printable # and secret_b64 in their place. _user = info.get("username", "") _password = info.get("password", "") _passthrough = {k: v for k, v in info.items() if k not in ("username", "password")} _log("auth", username=_user, principal=_user, **encode_secret(_password), **_passthrough) # Decide connection: accept-all > cred list > deny. cred = (info.get("username", ""), info.get("password", "")) accepted = ( MQTT_ACCEPT_ALL or (cred in _MQTT_CREDS if _MQTT_CREDS else False) ) if accepted: self._auth = True self._transport.write(_CONNACK_ACCEPTED) else: self._transport.write(_CONNACK_NOT_AUTH) self._transport.close() elif pkt_type == 8: # SUBSCRIBE if not self._auth: self._transport.close() continue packet_id, subs = _parse_subscribe(payload) granted_qos = [1] * len(subs) # grant QoS 1 for all self._transport.write(_suback(packet_id, granted_qos)) # Immediately send retained publishes matching topics for sub_topic, _ in subs: _log("subscribe", src=self._peer[0], topics=[sub_topic]) for t, v in self._topics.items(): # simple match: if topic ends with #, it matches prefix if sub_topic.endswith("#"): prefix = sub_topic[:-1] if t.startswith(prefix): self._transport.write(_publish(t, str(v))) elif sub_topic == t: self._transport.write(_publish(t, str(v))) elif pkt_type == 3: # PUBLISH if not self._auth: self._transport.close() continue topic, packet_id, data = _parse_publish(payload, qos) # Attacker command received! _log("publish", src=self._peer[0], topic=topic, payload=data.decode(errors="replace")) if qos == 1: puback = bytes([0x40, 0x02]) + struct.pack(">H", packet_id) self._transport.write(puback) elif pkt_type == 12: # PINGREQ self._transport.write(b"\xd0\x00") # PINGRESP elif pkt_type == 14: # DISCONNECT self._transport.close() else: _log("packet", src=self._peer[0], pkt_type=pkt_type) self._transport.close() def connection_lost(self, exc): _log("disconnect", src=self._peer[0] if self._peer else "?") async def main(): _log("startup", msg=f"MQTT server starting as {NODE_NAME}") loop = asyncio.get_running_loop() server = await loop.create_server(MQTTProtocol, "0.0.0.0", PORT) # nosec B104 async with server: await server.serve_forever() if __name__ == "__main__": asyncio.run(main())