From 233e28579d51799a44d0012e3df585e577717501 Mon Sep 17 00:00:00 2001 From: Naeel Date: Sun, 5 Apr 2026 09:06:31 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20MQTT=20ACL=20=E2=80=94=20allow=20bridge?= =?UTF-8?q?=20subscribe=20+/telemetry/+,=20fix=20device=20topic=20{ns}/tel?= =?UTF-8?q?emetry/{deviceId}?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/api/handler/iot_device_handler.go | 63 +++++++++++++++------- 1 file changed, 45 insertions(+), 18 deletions(-) diff --git a/internal/api/handler/iot_device_handler.go b/internal/api/handler/iot_device_handler.go index 22dcd0e..285f92b 100644 --- a/internal/api/handler/iot_device_handler.go +++ b/internal/api/handler/iot_device_handler.go @@ -201,19 +201,33 @@ func (h *Handler) MQTTAuth(w http.ResponseWriter, r *http.Request) { // Продолжаем — это некритично, устройство всё равно авторизовано } - // Проверки пройдены — разрешаем подключение. - // ACL ограничивает устройство только его собственным топиком: - // publish: {namespace}/{deviceId} (данные устройства) - // subscribe: {namespace}/{deviceId} (команды устройству, если нужны) - // deny all: всё остальное запрещено — нельзя читать чужие данные - ownerTopic := ns + "/" + deviceID + "/#" + // Формируем ACL правила для этого подключения. + // Топик устройства: "{namespace}/telemetry/{deviceId}" + // Это то что строит эмулятор: topicPrefix + "telemetry/" + device_id + // topicPrefix = "{ns}/" → итого "{ns}/telemetry/{deviceId}" + deviceTopic := ns + "/telemetry/" + deviceID + + var aclRules []aclRule + + if req.ClientID == "sless-iot-bridge" { + // Bridge подписывается на "+/telemetry/+" (все тенанты) — разрешаем + // Bridge НЕ публикует через MQTT — только читает + aclRules = []aclRule{ + {Permission: "allow", Action: "subscribe", Topic: "+/telemetry/+"}, + {Permission: "deny", Action: "all", Topic: "#"}, + } + } else { + // Обычное IoT устройство: только свой топик + aclRules = []aclRule{ + {Permission: "allow", Action: "publish", Topic: deviceTopic}, + {Permission: "allow", Action: "subscribe", Topic: deviceTopic}, + {Permission: "deny", Action: "all", Topic: "#"}, + } + } + writeJSON(w, http.StatusOK, mqttAuthResponse{ Result: "allow", - ACL: []aclRule{ - {Permission: "allow", Action: "publish", Topic: ownerTopic}, - {Permission: "allow", Action: "subscribe", Topic: ownerTopic}, - {Permission: "deny", Action: "all", Topic: "#"}, - }, + ACL: aclRules, }) } @@ -392,8 +406,9 @@ type mqttAclRequest struct { // Вызывается EMQX для каждого pub/sub действия. // НЕ защищён JWT — доступен только из кластера. // -// Логика: клиент видит только топики вида {namespace}/{deviceId}/# -// Любой другой топик — deny и disconnect. +// Логика разрешений: +// 1. Bridge clientid "sless-iot-bridge" — subscribe на любой топик (нужен для "+/telemetry/+") +// 2. IoT Device (username "{ns}_{deviceId}") — publish/subscribe на "{ns}/telemetry/{deviceId}" func (h *Handler) MQTTAcl(w http.ResponseWriter, r *http.Request) { var req mqttAclRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -401,7 +416,19 @@ func (h *Handler) MQTTAcl(w http.ResponseWriter, r *http.Request) { return } + // Специальный случай: mqtt-bridge подписывается на "+/telemetry/+" (все тенанты). + // Публикация bridge НЕ разрешена — только чтение. + if req.ClientID == "sless-iot-bridge" { + if req.Action == "subscribe" { + writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "allow"}) + } else { + writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) + } + return + } + // Парсим username → namespace + deviceId (формат: "{ns}_{deviceId}") + // strings.Index находит ПЕРВЫЙ '_' — namespace содержит только дефисы idx := strings.Index(req.Username, "_") if idx < 0 { writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) @@ -414,11 +441,11 @@ func (h *Handler) MQTTAcl(w http.ResponseWriter, r *http.Request) { return } - // Разрешаем только топики этого устройства: {ns}/{deviceId}/... - // Используем strings.HasPrefix — wildcard не нужен, проверяем prefix реального топика. - allowedPrefix := ns + "/" + deviceID + "/" - exactMatch := ns + "/" + deviceID - if strings.HasPrefix(req.Topic, allowedPrefix) || req.Topic == exactMatch { + // Разрешённый топик: "{ns}/telemetry/{deviceId}" + // Это то что эмулятор строит как: topicPrefix + "telemetry/" + device_id + // topicPrefix = "{ns}/" → итого "{ns}/telemetry/{deviceId}" + allowedTopic := ns + "/telemetry/" + deviceID + if req.Topic == allowedTopic { writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "allow"}) return }