// 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" "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. type iotDeviceUpdateRequest struct { Enabled *bool `json:"enabled"` } // 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 } // 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) { ns := pathVar(r, "namespace") if ns == "" { ns = "default" } 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 } phase := "Active" if !enabled { phase = "Disabled" } device := &store.Device{ Namespace: ns, Name: req.Name, DeviceID: req.DeviceID, Enabled: enabled, MQTTPassword: password, Metadata: req.Metadata, Phase: phase, } if err := h.Devices.Create(r.Context(), device); err != nil { 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 { h.Log.Error("reload device after create", "namespace", ns, "name", req.Name, "err", err) writeJSON(w, http.StatusInternalServerError, errResp("failed to reload device")) 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 := pathVar(r, "namespace") if ns == "" { ns = "default" } // 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 := pathVar(r, "namespace") if ns == "" { ns = "default" } 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 := pathVar(r, "namespace") if ns == "" { ns = "default" } 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 := pathVar(r, "namespace") if ns == "" { ns = "default" } 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 { writeJSON(w, http.StatusBadRequest, errResp("enabled field is required")) return } err := h.Devices.UpdateEnabled(r.Context(), ns, name, *req.Enabled) if err != nil { if errors.Is(err, store.ErrNotFound) { writeJSON(w, http.StatusNotFound, errResp("iot device not found")) return } h.Log.Error("update device", "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, "")) }