// devices.go — CRUD устройств на PostgreSQL. // // Endpoints: // // POST /v1/namespaces/{ns}/iot/devices — создать (отдаёт mqtt_password ОДИН раз) // GET /v1/namespaces/{ns}/iot/devices — список (без паролей) // GET /v1/namespaces/{ns}/iot/devices/{name} — одно (без пароля) // DELETE /v1/namespaces/{ns}/iot/devices/{name} — удалить // PATCH /v1/namespaces/{ns}/iot/devices/{name} — enabled package handler import ( "crypto/rand" "encoding/hex" "errors" "net/http" "regexp" "strconv" "time" "golang.org/x/crypto/bcrypt" "gitea.services.ngcloud.ru/Nail/IoT/internal/service/store" ) // iotDeviceCreateRequest — тело POST. type iotDeviceCreateRequest struct { Name string `json:"name"` DeviceID string `json:"device_id"` Enabled *bool `json:"enabled"` Metadata map[string]string `json:"metadata,omitempty"` } // iotDeviceUpdateRequest — тело PATCH. Хотя бы одно поле обязательно: // enabled и/или metadata (metadata раньше нельзя было обновить — ревью 2026-10-10). type iotDeviceUpdateRequest struct { Enabled *bool `json:"enabled,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` } // iotDeviceResponse — ответ API. MQTTPassword заполняется ТОЛЬКО при создании // устройства (отдаётся один раз) и НИКОГДА при чтении — чтобы пароль не утекал // через GET и не хранился в логах/истории. type iotDeviceResponse struct { Name string `json:"name"` Namespace string `json:"namespace"` DeviceID string `json:"device_id"` Enabled bool `json:"enabled"` Phase string `json:"phase"` MQTTUsername string `json:"mqtt_username,omitempty"` MQTTPassword string `json:"mqtt_password,omitempty"` TopicPrefix string `json:"topic_prefix,omitempty"` // TelemetryTopic — полный MQTT-топик телеметрии устройства «ns/telemetry/device». // Явное поле (в отличие от topic_prefix = «ns/»): Terraform должен знать // точный топик для публикации/подписки (ревью 2026-10-10). TelemetryTopic string `json:"telemetry_topic"` LastConnected string `json:"last_connected,omitempty"` // Metadata — без omitempty: всегда присутствует в ответе. Пустой → «{}», // иначе Terraform видел бы drift «{}» против null (ревью 2026-10-10). Metadata map[string]string `json:"metadata"` CreatedAt string `json:"created_at,omitempty"` } // deviceToResponse конвертирует устройство из БД в ответ API. // password отдаётся только если непустая — только на создании, при чтении // передаётся пустая строка (пароль повторно не показывается). func deviceToResponse(d *store.Device, password string) iotDeviceResponse { // Пустой metadata нормализуем в «{}» (а не null/omit): иначе Terraform видел // бы drift между «{}» и отсутствующим полем. nil-карту не мутируем — подменяем // только для сериализации ответа. metadata := d.Metadata if metadata == nil { metadata = map[string]string{} } resp := iotDeviceResponse{ Name: d.Name, Namespace: d.Namespace, DeviceID: d.DeviceID, Enabled: d.Enabled, Phase: d.Phase, MQTTUsername: d.Namespace + "_" + d.DeviceID, MQTTPassword: password, TopicPrefix: d.Namespace + "/", TelemetryTopic: d.Namespace + "/telemetry/" + d.DeviceID, Metadata: metadata, } if d.LastConnected != nil && !d.LastConnected.IsZero() { resp.LastConnected = d.LastConnected.UTC().Format(time.RFC3339) } if !d.CreatedAt.IsZero() { // RFC3339 — как last_connected: единый формат для Terraform (drift между // POST/GET из-за формата «2006-01-02 15:04:05 UTC» устранён). resp.CreatedAt = d.CreatedAt.UTC().Format(time.RFC3339) } return resp } // generateMQTTPassword — 32 случайных байта в hex (как старый контроллер). // crypto/rand ошибку даёт только при сбое системного PRNG — 3 попытки // (ревью 2026-08-16: не возвращать пустой пароль). func generateMQTTPassword() (string, error) { var lastErr error for attempt := 0; attempt < 3; attempt++ { buf := make([]byte, 32) if _, err := rand.Read(buf); err == nil { return hex.EncodeToString(buf), nil } else { lastErr = err } } return "", lastErr } // hashPassword — bcrypt-хэш пароля устройства. EMQX (postgres-auth) проверяет // bcrypt (см. emqx/emqx.conf password_hash_algorithm). В БД хранится ТОЛЬКО хэш; // открытый пароль отдаётся один раз при создании устройства. func hashPassword(plain string) (string, error) { h, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost) if err != nil { return "", err } return string(h), nil } // identRe — допустимый charset идентификаторов (namespace/name/device_id): // только [a-zA-Z0-9] и дефисы, не с дефиса. Исключает _, +, #, /, пробелы, юникод. // Причины запретов (ревью 2026-10-10): // - _ — username = «namespace_device_id»; _ делает его неоднозначным // (auth-запрос EMQX: namespace || '_' || device_id, LIMIT 1 без ORDER BY // → может сматчить чужое устройство); // - +/# — wildcard MQTT: в ACL-топике даст подписку/публикацию чужих топиков; // - / — ломает топик «ns/telemetry/device» (лишний уровень). var identRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-]*$`) // validateIdentifier возвращает текст ошибки (или "") для идентификатора value. // what — имя поля для сообщения, maxLen — максимальная длина (namespace ≤ 56: // имя БД tenant_ усекается PG до 63 байт). func validateIdentifier(value, what string, maxLen int) string { if value == "" { return what + " is required" } if len(value) > maxLen { return what + " is too long (max " + strconv.Itoa(maxLen) + " chars)" } if !identRe.MatchString(value) { return what + " must match [a-zA-Z0-9][a-zA-Z0-9-]* (no _, +, #, /, spaces)" } return "" } // CreateIoTDevice — POST /v1/namespaces/{ns}/iot/devices. func (h *Handler) CreateIoTDevice(w http.ResponseWriter, r *http.Request) { // Порядок важен: сначала разбор тела (400 «invalid JSON»), потом валидация // идентификаторов — так и было до вынесения общей проверки. ns := namespaceFromPath(r) var req iotDeviceCreateRequest if err := decodeJSON(w, r, &req); err != nil { writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error())) return } // Валидация идентификаторов до записи в БД: единый charset и лимиты для // namespace (из URL), name и device_id. Критично для MQTT (username/топик) // и PG (имя БД tenant_): плохие символы ломают auth/ACL или дают чужой // доступ (см. identRe). Ошибка 400 с понятным текстом — для Terraform. if msg := validateIdentifier(ns, "namespace", 56); msg != "" { writeJSON(w, http.StatusBadRequest, errResp(msg)) return } if msg := validateIdentifier(req.Name, "name", 64); msg != "" { writeJSON(w, http.StatusBadRequest, errResp(msg)) return } if msg := validateIdentifier(req.DeviceID, "device_id", 64); msg != "" { writeJSON(w, http.StatusBadRequest, errResp(msg)) return } enabled := true if req.Enabled != nil { enabled = *req.Enabled } password, err := generateMQTTPassword() if err != nil { h.Log.Error("generate device password", "err", err) writeJSON(w, http.StatusInternalServerError, errResp("failed to generate credentials")) return } // В БД храним bcrypt-хэш (EMQX проверяет bcrypt — emqx/emqx.conf). Открытый // пароль отдаём ОДИН раз в ответе 201 (иначе его негде взять). hash, err := hashPassword(password) if err != nil { h.Log.Error("hash device password", "err", err) writeJSON(w, http.StatusInternalServerError, errResp("failed to generate credentials")) return } phase := "Active" if !enabled { phase = "Disabled" } device := &store.Device{ Namespace: ns, Name: req.Name, DeviceID: req.DeviceID, Enabled: enabled, MQTTPassword: hash, Metadata: req.Metadata, Phase: phase, } if err := h.Devices.Create(r.Context(), device); err != nil { if errors.Is(err, store.ErrDeviceIDExists) { writeJSON(w, http.StatusConflict, errResp("device_id already exists in this namespace")) return } if errors.Is(err, store.ErrAlreadyExists) { writeJSON(w, http.StatusConflict, errResp("iot device already exists")) return } h.Log.Error("create device", "namespace", ns, "name", req.Name, "err", err) writeJSON(w, http.StatusInternalServerError, errResp("failed to create device")) return } // Перечитываем устройство из БД: после INSERT нужны БД-поля created_at // (и phase из БД), которых нет в in-memory структуре. Иначе POST отдал бы // пустой created_at → drift между POST и GET (ревью 2026-10-10, «Что // стабилизировать в REST для Terraform»). created, err := h.Devices.GetByName(r.Context(), ns, req.Name) if err != nil { // Устройство УЖЕ создано (INSERT прошёл): не отвечаем 500 — иначе пароль // (отдаётся один раз) потерялся бы, а повтор дал бы 409. Отдаём созданный // объект из памяти (created_at будет нулевым, уточнится следующим GET). h.Log.Error("reload device after create (возвращаю in-memory без created_at)", "namespace", ns, "name", req.Name, "err", err) writeJSON(w, http.StatusCreated, deviceToResponse(device, password)) return } // Пароль отдаётся ТОЛЬКО здесь, один раз в ответе 201. Дальше (GET/PATCH/ // LIST) он не возвращается — иначе пароль утекал бы через чтение и в // историю/логи. Это блокер для Terraform: без пароля устройство нельзя // подключить к MQTT, значит ресурс не создать. Именно поэтому пароль // пробрасывается в deviceToResponse только в этой точке (GetByName его // не отдаёт — см. P0-3 ч.2). writeJSON(w, http.StatusCreated, deviceToResponse(created, password)) } // ListIoTDevices — GET /v1/namespaces/{ns}/iot/devices (без паролей). // Пагинация: ?limit=N&offset=M. limit — дефолт 100, потолок 1000 (защита от // безлимитного запроса при ошибке парсинга). func (h *Handler) ListIoTDevices(w http.ResponseWriter, r *http.Request) { ns, ok := requireNamespace(w, r) if !ok { return } // limit: дефолт 100, потолок 1000 — иначе ошибка парсинга/омиссия = безлимит // (ревью 2026-10-10). limit := 100 if v, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && v > 0 { limit = v } if limit > 1000 { limit = 1000 } offset := 0 if v, err := strconv.Atoi(r.URL.Query().Get("offset")); err == nil && v > 0 { offset = v } devices, err := h.Devices.List(r.Context(), ns, limit, offset) if err != nil { h.Log.Error("list devices", "namespace", ns, "err", err) writeJSON(w, http.StatusInternalServerError, errResp("failed to list devices")) return } result := make([]iotDeviceResponse, 0, len(devices)) for i := range devices { result = append(result, deviceToResponse(&devices[i], "")) } writeJSON(w, http.StatusOK, result) } // GetIoTDevice — GET /v1/namespaces/{ns}/iot/devices/{name} (с паролем). func (h *Handler) GetIoTDevice(w http.ResponseWriter, r *http.Request) { ns, ok := requireNamespace(w, r) if !ok { return } name := pathVar(r, "name") device, err := h.Devices.GetByName(r.Context(), ns, name) if err != nil { if errors.Is(err, store.ErrNotFound) { writeJSON(w, http.StatusNotFound, errResp("iot device not found")) return } h.Log.Error("get device", "namespace", ns, "name", name, "err", err) writeJSON(w, http.StatusInternalServerError, errResp("failed to get device")) return } writeJSON(w, http.StatusOK, deviceToResponse(device, "")) } // DeleteIoTDevice — DELETE /v1/namespaces/{ns}/iot/devices/{name}. func (h *Handler) DeleteIoTDevice(w http.ResponseWriter, r *http.Request) { ns, ok := requireNamespace(w, r) if !ok { return } name := pathVar(r, "name") err := h.Devices.Delete(r.Context(), ns, name) if err != nil { if errors.Is(err, store.ErrNotFound) { writeJSON(w, http.StatusNotFound, errResp("iot device not found")) return } h.Log.Error("delete device", "namespace", ns, "name", name, "err", err) writeJSON(w, http.StatusInternalServerError, errResp("failed to delete device")) return } w.WriteHeader(http.StatusNoContent) } // UpdateIoTDevice — PATCH /v1/namespaces/{ns}/iot/devices/{name} (enabled). func (h *Handler) UpdateIoTDevice(w http.ResponseWriter, r *http.Request) { ns, ok := requireNamespace(w, r) if !ok { return } name := pathVar(r, "name") var req iotDeviceUpdateRequest if err := decodeJSON(w, r, &req); err != nil { writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error())) return } if req.Enabled == nil && req.Metadata == nil { writeJSON(w, http.StatusBadRequest, errResp("enabled или metadata обязательны")) return } // Обновляем только переданные поля (enabled и/или metadata). if req.Enabled != nil { if err := h.Devices.UpdateEnabled(r.Context(), ns, name, *req.Enabled); err != nil { if errors.Is(err, store.ErrNotFound) { writeJSON(w, http.StatusNotFound, errResp("iot device not found")) return } h.Log.Error("update device enabled", "namespace", ns, "name", name, "err", err) writeJSON(w, http.StatusInternalServerError, errResp("failed to update device")) return } } if req.Metadata != nil { if err := h.Devices.UpdateMetadata(r.Context(), ns, name, req.Metadata); err != nil { if errors.Is(err, store.ErrNotFound) { writeJSON(w, http.StatusNotFound, errResp("iot device not found")) return } h.Log.Error("update device metadata", "namespace", ns, "name", name, "err", err) writeJSON(w, http.StatusInternalServerError, errResp("failed to update device")) return } } device, err := h.Devices.GetByName(r.Context(), ns, name) if err != nil { writeJSON(w, http.StatusInternalServerError, errResp("failed to reload device")) return } writeJSON(w, http.StatusOK, deviceToResponse(device, "")) }