Files
sless/internal/api/handler/iot_device_handler.go
T
Naeel 1e53766c46 feat(iot): Этапы 2-7 — MQTT auth, IoT API, EMQX, mqtt-bridge, E2E demo
Этап 2+4: internal/api/handler/iot_device_handler.go
  - MQTTAuth: POST /internal/mqtt/auth (без JWT, для EMQX)
  - CreateIoTDevice, ListIoTDevices, GetIoTDevice (c password), DeleteIoTDevice, UpdateIoTDevice
  - crypto/subtle.ConstantTimeCompare против timing attacks

Этап 4: internal/api/router.go
  - /v1/namespaces/{ns}/iot/devices CRUD
  - /internal/mqtt/auth (без JWT middleware)

Этап 3: deployments/k8s/emqx.yaml
  - EMQX 5.5.1, emqx.conf (HOCON) с HTTP auth backend
  - Сервис exposure: 1883 (MQTT), 8083 (WS), 18083 (Dashboard)

Этап 3: iot/cmd/mqtt-bridge/main.go
  - paho.mqtt.golang: подписка на +/telemetry/+
  - amqp091-go: publish в iot.{namespace}.telemetry
  - deployments/k8s/iot-mqtt-bridge.yaml

Этап 7: examples/IOT/ — E2E demo (main.tf, handler.py, README.md)

go.mod: добавлен github.com/eclipse/paho.mqtt.golang v1.5.1
go build ./... — ошибок нет
2026-04-04 09:45:23 +03:00

354 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.
// Создано: 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-naeel.giteak8s.services.ngcloud.ru/naeel/sless/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"
type mqttAuthResponse struct {
Result string `json:"result"`
}
// ——————————————————————————————————————————
// Вспомогательные функции
// ——————————————————————————————————————————
// 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)
// Продолжаем — это некритично, устройство всё равно авторизовано
}
// Проверки пройдены — разрешаем подключение
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "allow"})
}
// ——————————————————————————————————————————
// 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, ""))
}