Files
IoT/internal/service/api/handler/devices.go
T
Repinoid 59bd743c87 feat(iot): PATCH обновляет metadata (Terraform)
iotDeviceUpdateRequest получил поле metadata; UpdateIoTDevice обновляет переданные
поля (enabled и/или metadata). Добавлен store.UpdateMetadata. Хотя бы одно поле
обязательно. Раньше metadata нельзя было изменить (только enabled).
2026-10-10 19:18:01 +03:00

355 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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. Хотя бы одно поле обязательно:
// 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
}
// 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_<ns> усекается 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_<ns>): плохие символы ломают 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 && 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, ""))
}