// mqtt_auth.go — HTTP-auth и ACL для EMQX (MQTT CONNECT / pub-sub). // // Endpoints (без JWT, вызываются EMQX внутри платформы): // // POST /internal/mqtt/auth — аутентификация: всегда HTTP 200, {"result":"allow"|"deny"} // POST /internal/mqtt/acl — авторизация pub/sub // // Источник устройств — таблица iot_devices (не k8s Secrets). package handler import ( "crypto/subtle" "encoding/json" "errors" "net/http" "strings" "gitea.services.ngcloud.ru/Nail/IoT/internal/service/store" ) // mqttAuthRequest — тело запроса от EMQX 5.x (username, password, clientid, peerhost). type mqttAuthRequest struct { Username string `json:"username"` Password string `json:"password"` ClientID string `json:"clientid"` PeerHost string `json:"peerhost"` } // mqttAuthResponse — ответ для EMQX (всегда HTTP 200). type mqttAuthResponse struct { Result string `json:"result"` ACL []aclRule `json:"acl,omitempty"` } // aclRule — правило ACL для EMQX HTTP auth. type aclRule struct { Permission string `json:"permission"` Action string `json:"action"` Topic string `json:"topic"` } // mqttAclRequest — тело запроса ACL от EMQX. type mqttAclRequest struct { Username string `json:"username"` ClientID string `json:"clientid"` Action string `json:"action"` Topic string `json:"topic"` } // MQTTAuth — POST /internal/mqtt/auth. // // Логика: // 1. bridge-креды (MQTT_USERNAME/MQTT_PASSWORD из env) — проверяются до парсинга; // 2. username "{ns}_{deviceId}" → поиск устройства в iot_devices; // 3. constant-time сравнение пароля; проверка enabled; // 4. best-effort last_connected; ACL по топику устройства. func (h *Handler) MQTTAuth(w http.ResponseWriter, r *http.Request) { var req mqttAuthRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) return } // Внутренний bridge-клиент: фиксированные креды, ACL выдаётся ниже. if h.Cfg.MQTTUsername != "" && req.Username == h.Cfg.MQTTUsername { if subtle.ConstantTimeCompare([]byte(h.Cfg.MQTTPassword), []byte(req.Password)) == 1 { writeJSON(w, http.StatusOK, mqttAuthResponse{ Result: "allow", ACL: bridgeACLRules(), }) return } writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) return } // Парсим username: "{namespace}_{deviceId}" (первый '_' — разделитель). idx := strings.Index(req.Username, "_") if idx <= 0 || idx == len(req.Username)-1 { writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) return } ns := req.Username[:idx] deviceID := req.Username[idx+1:] device, err := h.Devices.GetByDeviceID(r.Context(), ns, deviceID) if err != nil { if !errors.Is(err, store.ErrNotFound) { h.Log.Error("mqtt auth: device lookup", "namespace", ns, "device", deviceID, "err", err) } writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) return } if !device.Enabled { writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) return } if subtle.ConstantTimeCompare([]byte(device.MQTTPassword), []byte(req.Password)) != 1 { writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) return } // best-effort: обновляем время последнего подключения h.Devices.TouchLastConnected(r.Context(), ns, deviceID) writeJSON(w, http.StatusOK, mqttAuthResponse{ Result: "allow", ACL: deviceACLRules(ns, deviceID), }) } // MQTTAcl — POST /internal/mqtt/acl. // // Bridge (clientID из env MQTT_CLIENT_ID): только subscribe. // Устройство ({ns}_{deviceId}): pub/sub в своём топике. func (h *Handler) MQTTAcl(w http.ResponseWriter, r *http.Request) { var req mqttAclRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) return } if req.ClientID == h.Cfg.MQTTClientID { if req.Action == "subscribe" { writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "allow"}) return } writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) return } idx := strings.Index(req.Username, "_") if idx <= 0 || idx == len(req.Username)-1 { writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) return } ns := req.Username[:idx] deviceID := req.Username[idx+1:] deviceTopic := ns + "/telemetry/" + deviceID switch { case req.Action == "publish" && req.Topic == deviceTopic: writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "allow"}) case req.Action == "subscribe" && (req.Topic == deviceTopic || req.Topic == "#"): writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "allow"}) default: writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) } } // bridgeACLRules — ACL для внутреннего bridge: subscribe на все telemetry-топики. func bridgeACLRules() []aclRule { return []aclRule{ {Permission: "allow", Action: "subscribe", Topic: "+/telemetry/+"}, {Permission: "deny", Action: "all", Topic: "#"}, } } // deviceACLRules — ACL для устройства: только свой топик телеметрии. func deviceACLRules(ns, deviceID string) []aclRule { topic := ns + "/telemetry/" + deviceID return []aclRule{ {Permission: "allow", Action: "publish", Topic: topic}, {Permission: "allow", Action: "subscribe", Topic: topic}, {Permission: "deny", Action: "all", Topic: "#"}, } }