Домашний MQTT я два года держал на малине в локалке: пароля не было вовсе, allow_anonymous true, и это всех устраивало — сеть домашняя, чужих нет. А потом я захотел видеть теплицу из отпуска и перенёс брокер на дешёвый VPS, открыв порт 1883 в интернет. К вечеру того же дня в логах брокера появились подключения с незнакомых адресов: боты сканируют 1883 одним из первых. А через неделю я сел в кафе, снял дамп трафика на своём ноутбуке в той же Wi-Fi сети и за минуту нашёл в нём свой пароль от брокера — открытым текстом, посреди пакета CONNECT.
Пакет MQTT-подключения не шифруется: CONNECT с логином и паролем летит как есть, и любой, кто стоит между платой и брокером, читает его как открытку. Структуру того пакета мы разбирали в уроке про устройство протокола — там она была преимуществом: минимум накладных расходов. В интернете та же простота превращается в дыру: пароль на виду у каждого роутера на пути от ESP32 до дата-центра, а их — десяток.
Лечение — TLS, тот самый протокол, что прячет HTTPS. У MQTT-через-TLS свой порт: 8883 вместо 1883. В этом уроке переносим клиента с WiFiClient на WiFiClientSecure, платим памятью и секундами на handshake и разбираемся, почему самый популярный совет в интернете — вызвать setInsecure() — это замок, повешенный рядом с дверью.
WiFiClientSecure: что меняется в скетче
По коду разница скромная. WiFiClient меняется на WiFiClientSecure, и именно этот espClient вы отдаёте PubSubClient — тот же приём, что в первом скетче с PubSubClient, поменялся только нижний слой. setServer остаётся setServer, меняется порт: 8883 вместо 1883. Вся новая магия происходит до connect: espClient.setCACert(root_ca) отдаёт библиотеке корневой сертификат, которым она проверит брокера. Сертификат — текстовая PEM-строка на килобайт-полтора, лежит прямо в скетче: для Let's Encrypt это корень ISRG Root X1, для своего брокера — ваш собственный CA, которым подписан сертификат сервера.
Пара слов о форме сертификата, чтобы не тратить вечер на пустой отказ. PEM-текст начинается строкой BEGIN CERTIFICATE и заканчивается строкой END CERTIFICATE — обе должны доехать до платы целиком, со всеми промежуточными строками по шестьдесят четыре символа: обрезанную копию библиотека молча примет, а проверка не сойдётся. И держите сертификат целиком за один вызов setCACert: частичными подгрузками это не делается.
Что делает проверка: при handshake брокер предъявляет свой сертификат, плата сверяет подпись с корнем из setCACert и имя в сертификате с адресом из setServer. Совпало — канал шифруется, и пароль в CONNECT больше не читается на проводах. Не совпало — connect падает с кодом -2 из таблицы кодов состояния: там он чаще значил «не тот порт», здесь — «не тот сервер», но диагностика та же.
Два условия, про которые пишут мелко. Первое: для проверки срока действия сертификата плате нужна текущая дата — без NTP плата живёт в 1970 году, и валидация срока бесполезна; configTime после подключения сети решает это за строку. Второе: имя в setServer должно совпадать с именем в сертификате — IP-адрес вместо домена роняет проверку, если в сертификате прописан именно домен. Оба случая я ловил на своей балконной плате: один раз она честно отказывалась подключаться, пока я не добавил NTP, второй — пока не вписал домен вместо IP.
Цена TLS: килобайты и секунды
Теперь счёт, который любят опускать. mbedTLS в момент handshake держит в куче десятки килобайт: на практике закладывайте 40-50 КБ свободного heap на момент connect. Если у вас Wi-Fi, MQTT и ещё Bluetooth — handshake упрётся в память и соединение не поднимется при полном порядке в сети, а диагностика будет указывать куда угодно, только не в память. Буферы приёма и передачи отъедают ещё по шестнадцать килобайт каждый — о них отдельно ниже.
Сам handshake — это пара кругов до сервера и обратно: клиент здоровается, сервер отдаёт сертификат, плата сверяет подпись и вычисляет сеансовые ключи. Каждый круг — полный путешествие туда-сюда по вашему каналу: на кабеле эти шаги незаметны, на мобильном интернете в поле растягиваются на секунды. Поэтому цифры ниже проверяйте в своих условиях — от сети к сети они гуляют в разы.
Вторая строка счёта — время. Соединение на 1883 в локалке поднимается за десятки-сотню миллисекунд; 8883 через интернет — это несколько кругов handshake до сервера и обратно, на моей балконной плате стабильно полторы-три секунды. Отсюда два следствия: reconnect-интервал короче пяти секунд смысла не имеет, каждый заход платит handshake заново; и connect всё так же блокирует loop, так что все правила кооперативного цикла из прошлого урока становятся ещё строже — во время handshake плата не публикует и не слушает.
И мнение с границей применимости: брокер на малине в домашней сети, порт не торчит в интернет — TLS можно не тянуть, пароля и ACL достаточно, а цена — память и секунды на каждом переподключении. Брокер на VPS, доступ из кафе и мобильного интернета, хоть один чужой Wi-Fi на пути — TLS обязателен, и обсуждению это не подлежит.
| Параметр | 1883 без TLS | 8883 с TLS |
|---|---|---|
| Клиент | WiFiClient | WiFiClientSecure + setCACert |
| RAM под соединение | почти ничего | 40-50 КБ heap на handshake плюс до 32 КБ буферов |
| Время connect | 0,05-0,2 с в локалке | 1,5-3 с через интернет |
| Логин и пароль в CONNECT | читаются на проводах | внутри шифрованного канала |
| Что ещё держит скетч | ничего | PEM-сертификат и NTP для даты |
| Кому обязателен | дом за NAT | VPS, облако, чужие сети |
setInsecure и порядок в скетче
Про setInsecure() отдельно, потому что этот вызов советуют в каждом втором туториале. Он отключает именно проверку сертификата: шифрование канала остаётся, но плате всё равно, кто на другом конце. Подменённый сервер — скомпрометированный роутер, ARP-спуфинг в том самом кафе — перестаёт замечаться: атакующий представляется вашим брокером, плата честно шифрует канал чужим ключом и сама отдаёт логин с паролем. Формально трафик защищён — фактически защита обходит вас по прямой, и об этом врезка ниже.
Моя граница: setInsecure живёт только на столе, когда я отлаживаю сам handshake и хочу отсечь проблемы сертификата от проблем сети, — и то с комментарием в коде, чтобы строка не уехала в прод. Постоянная плата получает setCACert всегда. Корня нет под рукой — сгенерируйте свой CA за десять минут: это дешевле, чем объяснять, почему насос включился сам.
В скетче ниже важен порядок: сеть, потом configTime для даты, потом setCACert и только потом connect. Переставите connect раньше сертификата — библиотека пойдёт проверять с пустым корнем, получите -2 и полчаса поиска причины в не том месте. И следите за циклом переподключения: каждый заход — снова килобайты и секунды, поэтому интервал я держу не короче пяти секунд. Сертификат закрывает канал, но не решает, кому в этом канале что можно: пароли и права пользователей — следующий урок.
#include <WiFiClientSecure.h>#include <PubSubClient.h> // Корневой сертификат брокера (PEM) — вырезка, полный держите целикомconst char ROOT_CA[] PROGMEM = R"EOF(-----BEGIN CERTIFICATE-----...строки вашего корневого сертификата...-----END CERTIFICATE-----)EOF"; WiFiClientSecure espClient; // вместо WiFiClientPubSubClient client(espClient); unsigned long lastPub = 0;const unsigned long PERIOD = 30000; void setup() { Serial.begin(115200); WiFi.begin("HomeNet", "12345678"); while (WiFi.status() != WL_CONNECTED) { delay(200); } configTime(3 * 3600, 0, "pool.ntp.org"); // дата для срока сертификата espClient.setCACert(ROOT_CA); // проверяем сервер espClient.setBufferSizes(512, 512); // мелкий payload: экономим heap client.setServer("iot.example.ru", 8883);} void loop() { if (!client.connected()) { Serial.print("TLS connect... "); if (client.connect("esp32-balcony-1", "balcony", "parol")) { Serial.println("ok"); client.subscribe("home/balcony/#"); } else { Serial.println("net: sertifikat, port ili pamyat"); delay(5000); // handshake дорогой: не долбим каждую секунду return; } } client.loop(); unsigned long now = millis(); if (now - lastPub >= PERIOD) { lastPub = now; client.publish("home/balcony/temp", "21.4"); }}По скелету пройдёмся снизу вверх. Корневой сертификат — массив ROOT_CA с атрибутом PROGMEM: на ESP32 это перенос строки из flash в кучу на время работы, а не постоянное проживание в оперативной памяти; полтора килобайта PEM-текста с BEGIN и END по краям. setBufferSizes стоит до connect — после подключения менять буферы поздно. И заметьте аргументы connect: clientId, логин, пароль. Эти три строки уйдут брокеру уже внутри шифрованного канала — именно поэтому сертификат проверяется раньше, чем учётные данные покидают плату.
Последний штрих — поведение после разрыва. TLS-канал рвётся по тем же причинам, что и обычный: Wi-Fi пропал, keepalive не дожили, брокер рестартовал. Каркас не меняется: client.connected() в loop, попытка в reconnect-ветке — просто каждая попытка теперь платит handshake, поэтому интервал я держу не короче пяти секунд, а журнал веду парами state= и heap=: второй показатель вылавливает проблемы с памятью раньше, чем они выловят вас.
- Посчитайте цену handshake по строкам hs: пять шагов по 600 мс — три секунды и сорок тысяч байт heap. Затем уменьшите шаг таймера до 300 мс и честно ответьте: изменилась реальная цена или только картинка в мониторе?
- Допишите в модель ветку повторной попытки: stage 9 ждёт десять секунд и возвращается к handshake. Каждый заход платит заново — именно поэтому в реальном скетче интервал не короче пяти секунд.
- Уберите stage-модель и подключите клиента напрямую: в симуляторе connect пройдёт без handshake — сравните с кодом урока, где между Wi-Fi и MQTT стоят сертификат и NTP.
- На реальной плате добавьте печать ESP.getFreeHeap() раз в минуту при 1883 и при 8883 — разница покажет цену TLS в ваших условиях, а не в усреднённой таблице.
- TLS закрывает два вопроса сразу: шифрование канала и проверка того, что брокер настоящий
- WiFiClientSecure вместо WiFiClient, порт 8883, setCACert с корневым сертификатом до connect
- setInsecure — только на столе: шифрование остаётся, а подменённый сервер остаётся незамеченным
- Цена: 40-50 КБ heap в момент handshake, connect на полторы-три секунды, буферы режутся setBufferSizes под мелкий payload
- Валидации сертификата нужна дата: configTime после Wi-Fi, иначе проверка срока бесполезна
- 1. Что именно делает setCACert()?
- 2. Чем плох setInsecure() на реальной плате?
- 3. Сколько времени занимает connect на 8883 через интернет?
- 4. Сколько RAM просит TLS на ESP32 в момент handshake при буферах по умолчанию?
- 5. Брокер на малине в домашней сети за NAT, доступ только из локалки. Ваш выбор?
Частые вопросы по теме урока
- Что именно делает setCACert()?
- setCACert отдаёт библиотеке корень, которым сверяется предъявленный брокером сертификат. Без него проверку сервера провести не на что. Включает проверку корневого сертификата сервера при handshake
- Чем плох setInsecure() на реальной плате?
- Канал шифруется, но с кем — неизвестно: атакующий представляется брокером и получает логин с паролем в зашифрованном, но чужом канале. Шифрование остаётся, но сервер не проверяется: подмену вы не заметите
- Сколько времени занимает connect на 8883 через интернет?
- Handshake до сервера в интернете занимает полторы-три секунды. Поэтому reconnect-интервал держат не короче пяти секунд. Полторы-три секунды: несколько кругов handshake
- Сколько RAM просит TLS на ESP32 в момент handshake при буферах по умолчанию?
- mbedTLS держит десятки килобайт на handshake; буферы приёма и передачи добавляют по 16 КБ. На слабо свободной куче соединение не поднимется. Десятки килобайт heap, плюс до 32 КБ буферов
- Брокер на малине в домашней сети за NAT, доступ только из локалки. Ваш выбор?
- Внутри доверенной сети цена TLS — память и секунды на каждом переподключении — себя не окупает. Пароль и ACL достаточно; TLS становится обязательным при выходе в интернет. 1883 без TLS: пароль и ACL, порт в интернет не торчит