Janus MQTT Proxy — это сервис, который я написал на Go в качестве хобби-проекта. Он подключается к MQTT-брокеру и подписывается на все события, а клиенты, в свою очередь, подключаются к proxy и общаются с ним как с MQTT-брокером.


Он позволяет:


  • ограничивать доступ клиентов к разным топикам. В том числе раздельно ограничивать доступ на чтение и запись;
  • подменять названия топиков и содержимое событий с помощью regexp-ов.

Зачем это нужно


В MQTT нет стандарта для структуры топиков и содержимого пакетов. Например, в каких-то сервисах включение лампочки выполняется отправкой 1 в топик /my/lamp/on, а где-то нужно отправить On в топик /my/lamp. Чтобы корректно связать между собой два таких сервиса, нужно явно указывать одному из них, что нужно отсылать и куда. Если топиков много, тогда конфиг будет огромным и совершенно нечитаемым.


Janus MQTT позволяет оставить сервисы как есть: каждый работает с топиками так, как ему удобно, а весь конфиг конвертации MQTT-пакетов сосредоточен в одном единственном месте. Причём чем больше разных сервисов/устройств вы используете, тем такой подход удобнее.


В идеале каждый сервис должен брать свою конфигурацию просто из структуры топиков. Допустим, среди них есть топик /light/main. «Ага, — должен подумать сервис, — значит, я могу включать и выключать свет, отправляя сообщения в этот топик». К сожалению, таких сообразительных сервисов я пока не встречал.


Что касается безопасности, то любая конфигурация умного дома состоит из модулей, как физических, так и программных. С помощью Janus MQTT мы можем дать этим устройствам/модулям доступ только туда, куда нужно. Это, кстати, не только улучшит безопасность, но и снизит уровень хаоса — никаких публикаций в топики с неизвестных клиентов.


Конфигурация Janus MQTT


Чтобы было понятнее, как использовать сервис, я разберу небольшую часть моего конфига — ту, которая описывает управление освещением.


У Janus MQTT есть основной конфиг, который содержит основные настройки и список пользователей. Для каждого пользователя задаётся пароль и отдельный конфиг преобразований — самое интересное происходит именно в нём:


broker_to_client: # настройка преобразования пакетов от брокера к клиенту

  # описание устройств для MQTT discovery.
  - topic: ^/devices/wb-gpio/controls/LIGHT_([^/]*)/meta/type$
    template: /homeassistant/light/{{.f1}}/config
    val_map:
      switch: >-
        {
        "command_topic":"/light/{{.f1}}/state",
        "state_topic":"/light/{{.f1}}/state",
        "name":"{{.f1}}"
        }

  - topic: ^/devices/wb-gpio/controls/LIGHT_([^/]*)$
    template: /light/{{.f1}}/state
    val_map: {0: OFF, 1: ON}

client_to_broker: # настройка преобразования пакетов от клиента к брокеру

  - topic: ^/light/([^/]*)/state$
    template: /devices/wb-gpio/controls/LIGHT_{{.f1}}/on
    val_map: {OFF: 0, ON: 1}

Каждое правило содержит регулярное выражение, которое извлекает данные из топика. Затем эти данные могут быть подставлены в шаблон названия нового топика и в шаблон пакета.


Любопытно, что в этой конфигурации для отправки команд и чтения состояния мой Home Assistant использует один и тот же топик, а уже внутри прокси эти топики разделяются на два.


Хак для MQTT discovery работает так: среди топиков Wiren Board есть специальные сервисные топики, которые описывают тип устройства. Насколько я понимаю, они используются только в стандартном интерфейсе управления. Они есть у каждой лампы и все равны switch. Все эти события retained и приходят один раз — в момент подключения. Я просто беру и подменяю все такие пакеты JSON-структурой, которая нужна для того, чтобы MQTT discovery подхватил эти устройства.


Устройство Janus MQTT


Сервис написан на Go и построен на базе библиотеки paho.mqtt.golang. Эта библиотека реализует MQTT-клиент для работы с брокером. Использовать её в качестве сервера никто не предполагал, поэтому его пришлось писать самому, используя части MQTT-клиента.


Принцип работы очевидный: выдаём себя за брокера, принимаем пакеты от клиентов, изменяем их и отправляем настоящему брокеру. То же самое в обратную сторону. Т.е. получаем такой MITM.


Самые интересные штуки:


  • поддержка различных уровней QoS — за счёт того что доставка на уровнях 2 и 1 осуществляется с подтверждением, получаем диалог клиента и сервера по каждому отправляемому сообщению;
  • из-за тех же QoS нужно генерить различные message_id, которые закодированы в uint16 и должны переиспользоваться;
  • Janus MQTT держит одно единственное подключение к брокеру и подписан на все сообщения. Уже внутри себя он разбирает подписки клиентов и присылает им только то, что нужно каждому из них;
  • не хотелось делать хранилище сообщений внутри сервиса, однако пришлось сделать in-memory хранилище retained-сообщений.

Обработка MQTT-пакетов от клиентов описана в функции client.serveIncoming, она запускается в горутине и читает из TCP-сокета. В этой функции описана высокоуровневая логика обработки MQTT-пакетов от клиентов:


  • ConnectPacket — аутентифицировать пользователя и вернуть Connack с подтверждением или ошибкой;
  • SubscribePacket — подтвердить подписку отправкой Suback и отправить retained-сообщения;
  • UnsubscribePacket — подтвердить отмену подписки отправкой Unsuback и отменить подписку;
  • PingreqPacket — отправить обратно Pingresp;
  • PublishPacket — запустить автомат отправки сообщения в брокер;
  • PubackPacket, PubrelPacket, PubcompPacket передаются в автоматы отправки сообщений;
  • DisconnectPacket — дисконнект.

Автоматы отправки сообщений в клиент и от клиента нужны только для поддержки различных уровней QoS. Всего в MQTT три уровня QoS:


  • QoS 0: отправка без подтверждения доставки;
  • QoS 1: отправка с подтверждением доставки;
  • QoS 2: отправка с подтверждением доставки один и только один раз.

Как мне кажется, уровень 1 и тем более уровень 2 не имеют большого смысла дома, но, тем не менее, реализовать их было интересно (уровень 2 я немного не доделал, поэтому пока что обрезаю весь QoS уровнем 1, протокол это позволяет).


Обработка publish-пакета от клиента с QOS=2


Обработка publish-пакета от брокера с QOS=2


Мой сценарий использования


Janus MQTT я использую для организации взаимодействия между тремя компонентами:


  • Wiren Board — основной контроллер;
  • Home Assistant — фронтенд (у него удобное приложение);
  • Yandex2mqtt — голосовое управление.

Wiren Board


Содержит кучу разных реле и датчиков. В нём же крутятся основные скрипты управления всем. На нём работает MQTT-брокер.


Yandex2mqtt


Реализует oAuth для аутентификации и шлюз для взаимодействия с API умного дома Яндекса. Я решил не тратить на это время и взял готовый компонент. Изначально его написал munrexio, потом bawdiest обернул в докер, а я немножко поправил.


Пока что в конфиге одна единственная лампочка, но зато самая важная:


$ mosquitto_sub -h localhost -u yandex2mqtt -P yandex2mqtt -t '#' -v
/light/LIVING_TABLE/state 1


Home Assistant


Фронтенд. Собирает статку и рисует разные графики. Позволяет управлять как светом, так и отоплением.


Заниматься настройкой Home Assistant мне не хочется, мне нужно, чтобы я запустил его и там сразу появились все мои устройства.


К счастью, в Home Assistant есть MQTT Discovery — это специальный режим, когда Home Assistant получает конфиг устройств прямо из MQTT. Там нужно создать специальные топики с JSON-структурами, описывающими устройства.


В итоге весь конфиг Home Assistant сводится к:


mqtt:
  username: !env_var MQTT_USER
  password: !env_var MQTT_PASS
  broker: !env_var MQTT_HOST
  discovery: true
  discovery_prefix: /homeassistant

Janus MQTT


Конфиг Janus MQTT для Home Assistant самый сложный. Однако он уложился в 100 строчек, при том что описывает 17 групп освещения, 6 термостатов с обратной связью по состоянию (вкл/выкл) и по температуре в комнате и 5 тёплых полов. Часть этого конфига приведена выше. Полный конфиг можно посмотреть тут.


Docker


Все сервисы работают в докере на NanoPi и описаны в docker-compose.yml.



В завершение


У меня сервис безостановочно работает с января, была пара мелких багов, которые я поправил, но в целом всё хорошо.
Весь докеробраз сервиса весит порядка 10 Mб. Слава Golang!


Сервис можно скачать/посмотреть тут :
https://github.com/phoenix-mstu/janus-mqtt-proxy.


Файл docker-compose можно посмотреть тут:
https://github.com/phoenix-mstu/smart_home/tree/master/raspberry.


И самое интересное, сервис можно пощупать вот тут: 52.59.242.204:26927, логин/пароль — habr/habr, протокол — MQTT, конечно же. Там настроен проброс порта в мою локалку. Специально для статьи запущен отдельный инстанс сервиса в докере со специальным конфигом, там можно:


  1. Управлять светом в моей кладовке (любопытно будет посмотреть на светомузыку).
  2. Оставить сообщение.

В качестве брокера выступает мой основной брокер-сервер — посмотрим, сломается или нет. Я, конечно, предпринял ряд мер для безопасности. Понятно, что DoS-атаку оно не выдержит — вы просто забьёте узкий канал. Будьте разумны.