// Создано: 2026-04-04 // iot_device_handler.go — HTTP handlers для IoT-устройств (CRUD) и MQTT auth. // // Endpoints: // POST /internal/mqtt/auth → MQTTAuth (без JWT, для EMQX) // POST /v1/namespaces/{ns}/iot/devices → CreateIoTDevice // GET /v1/namespaces/{ns}/iot/devices → ListIoTDevices // GET /v1/namespaces/{ns}/iot/devices/{name} → GetIoTDevice (включает credentials) // DELETE /v1/namespaces/{ns}/iot/devices/{name} → DeleteIoTDevice // PATCH /v1/namespaces/{ns}/iot/devices/{name} → UpdateIoTDevice // // MQTTAuth вызывается EMQX при каждом MQTT CONNECT: // - всегда возвращает HTTP 200 (non-200 = EMQX игнорирует backend) // - {"result": "allow"|"deny"} в теле // // GetIoTDevice — единственный endpoint возвращающий mqtt-password. // ListIoTDevices — без паролей (security by design). package handler import ( "crypto/subtle" "encoding/json" "net/http" "strings" "time" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" iotv1alpha1 "gitea.services.ngcloud.ru/Nail/IoT/api/v1alpha1" ) // —————————————————————————————————————————— // Типы запросов / ответов // —————————————————————————————————————————— // iotDeviceCreateRequest — тело POST при создании IoTDevice. type iotDeviceCreateRequest struct { // Name — имя k8s объекта IoTDevice (должно быть уникальным в namespace) Name string `json:"name"` // DeviceID — идентификатор устройства, используется в MQTT username и имени Secret DeviceID string `json:"device_id"` // Enabled — активно ли устройство с момента создания Enabled *bool `json:"enabled"` // Metadata — произвольные метаданные (модель, локация и т.д.) Metadata map[string]string `json:"metadata,omitempty"` } // iotDeviceUpdateRequest — тело PATCH при обновлении IoTDevice. type iotDeviceUpdateRequest struct { // Enabled — включить/отключить устройство Enabled *bool `json:"enabled"` } // iotDeviceResponse — ответ при чтении одного IoTDevice. // MQTTPassword заполняется только из GetIoTDevice (чтение из Secret). type iotDeviceResponse struct { Name string `json:"name"` Namespace string `json:"namespace"` DeviceID string `json:"device_id"` Enabled bool `json:"enabled"` Phase iotv1alpha1.IoTDevicePhase `json:"phase"` MQTTUsername string `json:"mqtt_username,omitempty"` MQTTPassword string `json:"mqtt_password,omitempty"` // только в GET /devices/{name} SecretName string `json:"secret_name,omitempty"` TopicPrefix string `json:"topic_prefix,omitempty"` LastConnected string `json:"last_connected,omitempty"` Message string `json:"message,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` CreatedAt string `json:"created_at,omitempty"` } // mqttAuthRequest — тело запроса от EMQX при MQTT CONNECT. // EMQX 5.x посылает JSON: 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. // result = "allow" | "deny" // ACL — список правил pub/sub, изолирует топики по устройству. type mqttAuthResponse struct { Result string `json:"result"` ACL []aclRule `json:"acl,omitempty"` } // aclRule — одно правило ACL для EMQX HTTP auth plugin. // permission: "allow" | "deny" // action: "publish" | "subscribe" | "all" // topic: точный топик или wildcard (#, +) type aclRule struct { Permission string `json:"permission"` Action string `json:"action"` Topic string `json:"topic"` } // —————————————————————————————————————————— // Вспомогательные функции // —————————————————————————————————————————— // deviceToResponse конвертирует IoTDevice CRD в ответ API. // password передаётся отдельно — берётся из Secret только в GetIoTDevice. func deviceToResponse(d *iotv1alpha1.IoTDevice, password string) iotDeviceResponse { resp := iotDeviceResponse{ Name: d.Name, Namespace: d.Namespace, DeviceID: d.Spec.DeviceID, Enabled: d.Spec.Enabled, Phase: d.Status.Phase, MQTTUsername: d.Status.MQTTUsername, MQTTPassword: password, SecretName: d.Status.SecretName, TopicPrefix: d.Status.TopicPrefix, Message: d.Status.Message, Metadata: d.Spec.Metadata, } if d.Status.LastConnected != nil && !d.Status.LastConnected.IsZero() { resp.LastConnected = d.Status.LastConnected.UTC().Format(time.RFC3339) } if !d.CreationTimestamp.IsZero() { resp.CreatedAt = d.CreationTimestamp.UTC().Format("2006-01-02 15:04:05 UTC") } return resp } // —————————————————————————————————————————— // MQTT Auth — Этап 2 // —————————————————————————————————————————— // MQTTAuth — POST /internal/mqtt/auth // Вызывается EMQX при каждом MQTT CONNECT. // НЕ защищён JWT middleware — доступен только из кластера (путь /internal/). // // Логика аутентификации: // 1. Распарсить username → namespace + deviceId // 2. Получить Secret iot-{deviceId} в namespace // 3. Constant-time сравнение пароля (защита от timing attacks) // 4. Проверить что IoTDevice существует и enabled=true // 5. Обновить status.lastConnected в IoTDevice func (h *Handler) MQTTAuth(w http.ResponseWriter, r *http.Request) { var req mqttAuthRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { // Плохой JSON от EMQX — deny, но не 400 (EMQX игнорирует non-200) writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) return } // Парсим username: "{namespace}_{deviceId}" // Namespace содержит только [a-z0-9-], первый "_" — разделитель. idx := strings.Index(req.Username, "_") if idx < 0 { writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) return } ns := req.Username[:idx] deviceID := req.Username[idx+1:] if ns == "" || deviceID == "" { writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) return } // Читаем Secret с MQTT credentials secretName := "iot-" + deviceID secret := &corev1.Secret{} if err := h.K8s.Get(r.Context(), client.ObjectKey{Namespace: ns, Name: secretName}, secret); err != nil { // Secret не найден или ошибка k8s — deny writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) return } // Constant-time сравнение пароля — защита от timing attacks storedPassword := secret.Data["mqtt-password"] if subtle.ConstantTimeCompare(storedPassword, []byte(req.Password)) != 1 { writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) return } // Проверяем что IoTDevice активно device := &iotv1alpha1.IoTDevice{} if err := h.K8s.Get(r.Context(), client.ObjectKey{Namespace: ns, Name: deviceID}, device); err != nil { // IoTDevice не найден (или удалён) — deny writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) return } if !device.Spec.Enabled { writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) return } // Обновляем lastConnected в статусе устройства (best-effort, ошибка не критична) now := metav1.NewTime(time.Now().UTC()) device.Status.LastConnected = &now if err := h.K8s.Status().Update(r.Context(), device); err != nil { h.Log.Warn("mqtt auth: failed to update lastConnected", "device", deviceID, "err", err) // Продолжаем — это некритично, устройство всё равно авторизовано } // Формируем 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: aclRules, }) } // —————————————————————————————————————————— // IoT Device CRUD — Этап 4 // —————————————————————————————————————————— // CreateIoTDevice — POST /v1/namespaces/{namespace}/iot/devices // Создаёт IoTDevice CRD. Контроллер асинхронно сгенерирует MQTT credentials. // credentials доступны через GET /devices/{name} после reconcile (phase=Active). func (h *Handler) CreateIoTDevice(w http.ResponseWriter, r *http.Request) { ns := namespace(r) var req iotDeviceCreateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error())) return } if req.Name == "" { writeJSON(w, http.StatusBadRequest, errResp("name is required")) return } if req.DeviceID == "" { writeJSON(w, http.StatusBadRequest, errResp("device_id is required")) return } enabled := true if req.Enabled != nil { enabled = *req.Enabled } device := &iotv1alpha1.IoTDevice{ ObjectMeta: metav1.ObjectMeta{ Name: req.Name, Namespace: ns, }, Spec: iotv1alpha1.IoTDeviceSpec{ DeviceID: req.DeviceID, Enabled: enabled, Metadata: req.Metadata, }, } if err := h.K8s.Create(r.Context(), device); err != nil { if errors.IsAlreadyExists(err) { writeJSON(w, http.StatusConflict, errResp("iot device already exists")) return } writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) return } writeJSON(w, http.StatusCreated, deviceToResponse(device, "")) } // ListIoTDevices — GET /v1/namespaces/{namespace}/iot/devices // Возвращает список устройств БЕЗ паролей (security by design). func (h *Handler) ListIoTDevices(w http.ResponseWriter, r *http.Request) { ns := namespace(r) list := &iotv1alpha1.IoTDeviceList{} if err := h.K8s.List(r.Context(), list, client.InNamespace(ns)); err != nil { writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) return } result := make([]iotDeviceResponse, 0, len(list.Items)) for i := range list.Items { result = append(result, deviceToResponse(&list.Items[i], "")) } writeJSON(w, http.StatusOK, result) } // GetIoTDevice — GET /v1/namespaces/{namespace}/iot/devices/{name} // Возвращает устройство включая mqtt_password из Secret. // mqtt_password нужен пользователю для конфигурации физического устройства. func (h *Handler) GetIoTDevice(w http.ResponseWriter, r *http.Request) { ns := namespace(r) name := pathVar(r, "name") device := &iotv1alpha1.IoTDevice{} if err := h.K8s.Get(r.Context(), client.ObjectKey{Namespace: ns, Name: name}, device); err != nil { if errors.IsNotFound(err) { writeJSON(w, http.StatusNotFound, errResp("iot device not found")) return } writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) return } // Читаем пароль из Secret — если ещё не создан (phase=Pending), password будет пустым password := "" if device.Status.SecretName != "" { secret := &corev1.Secret{} err := h.K8s.Get(r.Context(), client.ObjectKey{Namespace: ns, Name: device.Status.SecretName}, secret) if err == nil { password = string(secret.Data["mqtt-password"]) } // Если Secret не найден — просто передаём пустой пароль (устройство ещё provisioning) } writeJSON(w, http.StatusOK, deviceToResponse(device, password)) } // DeleteIoTDevice — DELETE /v1/namespaces/{namespace}/iot/devices/{name} // Удаляет IoTDevice CRD. Контроллер через finalizer удалит Secret каскадно. func (h *Handler) DeleteIoTDevice(w http.ResponseWriter, r *http.Request) { ns := namespace(r) name := pathVar(r, "name") device := &iotv1alpha1.IoTDevice{} if err := h.K8s.Get(r.Context(), client.ObjectKey{Namespace: ns, Name: name}, device); err != nil { if errors.IsNotFound(err) { writeJSON(w, http.StatusNotFound, errResp("iot device not found")) return } writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) return } if err := h.K8s.Delete(r.Context(), device); err != nil { writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) return } w.WriteHeader(http.StatusNoContent) } // UpdateIoTDevice — PATCH /v1/namespaces/{namespace}/iot/devices/{name} // Позволяет включить/отключить устройство (spec.enabled). // Контроллер увидит изменение и обновит status.phase. func (h *Handler) UpdateIoTDevice(w http.ResponseWriter, r *http.Request) { ns := namespace(r) name := pathVar(r, "name") var req iotDeviceUpdateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error())) return } if req.Enabled == nil { writeJSON(w, http.StatusBadRequest, errResp("enabled field is required")) return } device := &iotv1alpha1.IoTDevice{} if err := h.K8s.Get(r.Context(), client.ObjectKey{Namespace: ns, Name: name}, device); err != nil { if errors.IsNotFound(err) { writeJSON(w, http.StatusNotFound, errResp("iot device not found")) return } writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) return } device.Spec.Enabled = *req.Enabled if err := h.K8s.Update(r.Context(), device); err != nil { writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) return } writeJSON(w, http.StatusOK, deviceToResponse(device, "")) } // —————————————————————————————————————————— // MQTT ACL — авторизация pub/sub // —————————————————————————————————————————— // mqttAclRequest — тело запроса от EMQX при каждом publish/subscribe. type mqttAclRequest struct { Username string `json:"username"` ClientID string `json:"clientid"` Action string `json:"action"` // "publish" | "subscribe" Topic string `json:"topic"` } // MQTTAcl — POST /internal/mqtt/acl // Вызывается EMQX для каждого pub/sub действия. // НЕ защищён JWT — доступен только из кластера. // // Логика разрешений: // 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 { writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) 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"}) return } ns := req.Username[:idx] deviceID := req.Username[idx+1:] if ns == "" || deviceID == "" { writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) return } // Разрешённый топик: "{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 } writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) }