Files
DECNET/decnet/templates/mqtt/server.py
anti 19271f9319 fix(types): P3 — annotate transport in all template protocol servers; 0 errors in templates/
- asyncio.Protocol (TCP): _transport: asyncio.Transport | None = None + cast() in
  connection_made; assert guards in every method that directly accesses the field.
  Files: pop3, smtp, mqtt, postgres, mssql, mongodb, imap, ldap, redis, mysql, sip, vnc.
- asyncio.DatagramProtocol (UDP): _transport: asyncio.DatagramTransport | None = None.
  Files: snmp, tftp, SIPUDPProtocol.
- RDP: assert new_transport is not None after start_tls() to narrow Transport | None.
- FTP (Twisted): assert self.transport is not None + targeted type: ignore for imprecise
  Twisted stubs (misc/override/arg-type/attr-defined), IReactorTCP cast for listenTCP.
- conpot: proc.stdout is None guard before iteration.
- Bonus fixes surfaced by annotation:
  - smtp: get_payload(decode=True) bytes narrowing (arg-type on sha256)
  - postgres: rename shadowed `msg` param to `err_msg` in _handle_startup
  - mongodb: base64.binascii.Error → import binascii; binascii.Error
  - imap: result: list[int] = [] (var-annotated)
2026-05-01 01:09:14 -04:00

347 lines
13 KiB
Python

#!/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())