fix: MQTT ACL — allow bridge subscribe +/telemetry/+, fix device topic {ns}/telemetry/{deviceId}

This commit is contained in:
Naeel
2026-04-05 09:06:31 +03:00
parent b902e136ed
commit 233e28579d
+45 -18
View File
@@ -201,19 +201,33 @@ func (h *Handler) MQTTAuth(w http.ResponseWriter, r *http.Request) {
// Продолжаем — это некритично, устройство всё равно авторизовано // Продолжаем — это некритично, устройство всё равно авторизовано
} }
// Проверки пройдены — разрешаем подключение. // Формируем ACL правила для этого подключения.
// ACL ограничивает устройство только его собственным топиком: // Топик устройства: "{namespace}/telemetry/{deviceId}"
// publish: {namespace}/{deviceId} (данные устройства) // Это то что строит эмулятор: topicPrefix + "telemetry/" + device_id
// subscribe: {namespace}/{deviceId} (команды устройству, если нужны) // topicPrefix = "{ns}/" → итого "{ns}/telemetry/{deviceId}"
// deny all: всё остальное запрещено — нельзя читать чужие данные deviceTopic := ns + "/telemetry/" + deviceID
ownerTopic := ns + "/" + 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{ writeJSON(w, http.StatusOK, mqttAuthResponse{
Result: "allow", Result: "allow",
ACL: []aclRule{ ACL: aclRules,
{Permission: "allow", Action: "publish", Topic: ownerTopic},
{Permission: "allow", Action: "subscribe", Topic: ownerTopic},
{Permission: "deny", Action: "all", Topic: "#"},
},
}) })
} }
@@ -392,8 +406,9 @@ type mqttAclRequest struct {
// Вызывается EMQX для каждого pub/sub действия. // Вызывается EMQX для каждого pub/sub действия.
// НЕ защищён JWT — доступен только из кластера. // НЕ защищён 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) { func (h *Handler) MQTTAcl(w http.ResponseWriter, r *http.Request) {
var req mqttAclRequest var req mqttAclRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 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 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}") // Парсим username → namespace + deviceId (формат: "{ns}_{deviceId}")
// strings.Index находит ПЕРВЫЙ '_' — namespace содержит только дефисы
idx := strings.Index(req.Username, "_") idx := strings.Index(req.Username, "_")
if idx < 0 { if idx < 0 {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
@@ -414,11 +441,11 @@ func (h *Handler) MQTTAcl(w http.ResponseWriter, r *http.Request) {
return return
} }
// Разрешаем только топики этого устройства: {ns}/{deviceId}/... // Разрешённый топик: "{ns}/telemetry/{deviceId}"
// Используем strings.HasPrefix — wildcard не нужен, проверяем prefix реального топика. // Это то что эмулятор строит как: topicPrefix + "telemetry/" + device_id
allowedPrefix := ns + "/" + deviceID + "/" // topicPrefix = "{ns}/" → итого "{ns}/telemetry/{deviceId}"
exactMatch := ns + "/" + deviceID allowedTopic := ns + "/telemetry/" + deviceID
if strings.HasPrefix(req.Topic, allowedPrefix) || req.Topic == exactMatch { if req.Topic == allowedTopic {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "allow"}) writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "allow"})
return return
} }