iotDeviceUpdateRequest получил поле metadata; UpdateIoTDevice обновляет переданные поля (enabled и/или metadata). Добавлен store.UpdateMetadata. Хотя бы одно поле обязательно. Раньше metadata нельзя было изменить (только enabled).
355 lines
14 KiB
Go
355 lines
14 KiB
Go
// 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, ""))
|
||
}
|