Компоненты: - iot-operator: controller-manager (IoTDevice CRD) + REST API (порт 9090) - mqtt-bridge: MQTT (EMQX) → Kafka bridge - kafka-consumer: Kafka → Postgres pipeline Модуль: gitea.services.ngcloud.ru/Nail/IoT Все 3 бинарника собираются, import paths адаптированы.
455 lines
18 KiB
Go
455 lines
18 KiB
Go
// Создано: 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.services.ngcloud.ru/Nail/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"
|
||
// ACL — список правил pub/sub, изолирует топики по устройству.
|
||
type mqttAuthResponse struct {
|
||
Result string `json:"result"`
|
||
ACL []aclRule `json:"acl,omitempty"`
|
||
}
|
||
|
||
// aclRule — одно правило ACL для EMQX HTTP auth plugin.
|
||
// permission: "allow" | "deny"
|
||
// action: "publish" | "subscribe" | "all"
|
||
// topic: точный топик или wildcard (#, +)
|
||
type aclRule struct {
|
||
Permission string `json:"permission"`
|
||
Action string `json:"action"`
|
||
Topic string `json:"topic"`
|
||
}
|
||
|
||
// ——————————————————————————————————————————
|
||
// Вспомогательные функции
|
||
// ——————————————————————————————————————————
|
||
|
||
// 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)
|
||
// Продолжаем — это некритично, устройство всё равно авторизовано
|
||
}
|
||
|
||
// Формируем ACL правила для этого подключения.
|
||
// Топик устройства: "{namespace}/telemetry/{deviceId}"
|
||
// Это то что строит эмулятор: topicPrefix + "telemetry/" + device_id
|
||
// topicPrefix = "{ns}/" → итого "{ns}/telemetry/{deviceId}"
|
||
deviceTopic := ns + "/telemetry/" + deviceID
|
||
|
||
var aclRules []aclRule
|
||
|
||
if req.ClientID == "sless-iot-bridge" {
|
||
// Bridge подписывается на "+/telemetry/+" (все тенанты) — разрешаем
|
||
// Bridge НЕ публикует через MQTT — только читает
|
||
aclRules = []aclRule{
|
||
{Permission: "allow", Action: "subscribe", Topic: "+/telemetry/+"},
|
||
{Permission: "deny", Action: "all", Topic: "#"},
|
||
}
|
||
} else {
|
||
// Обычное IoT устройство: только свой топик
|
||
aclRules = []aclRule{
|
||
{Permission: "allow", Action: "publish", Topic: deviceTopic},
|
||
{Permission: "allow", Action: "subscribe", Topic: deviceTopic},
|
||
{Permission: "deny", Action: "all", Topic: "#"},
|
||
}
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, mqttAuthResponse{
|
||
Result: "allow",
|
||
ACL: aclRules,
|
||
})
|
||
}
|
||
|
||
// ——————————————————————————————————————————
|
||
// 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, ""))
|
||
}
|
||
|
||
// ——————————————————————————————————————————
|
||
// MQTT ACL — авторизация pub/sub
|
||
// ——————————————————————————————————————————
|
||
|
||
// mqttAclRequest — тело запроса от EMQX при каждом publish/subscribe.
|
||
type mqttAclRequest struct {
|
||
Username string `json:"username"`
|
||
ClientID string `json:"clientid"`
|
||
Action string `json:"action"` // "publish" | "subscribe"
|
||
Topic string `json:"topic"`
|
||
}
|
||
|
||
// MQTTAcl — POST /internal/mqtt/acl
|
||
// Вызывается EMQX для каждого pub/sub действия.
|
||
// НЕ защищён JWT — доступен только из кластера.
|
||
//
|
||
// Логика разрешений:
|
||
// 1. Bridge clientid "sless-iot-bridge" — subscribe на любой топик (нужен для "+/telemetry/+")
|
||
// 2. IoT Device (username "{ns}_{deviceId}") — publish/subscribe на "{ns}/telemetry/{deviceId}"
|
||
func (h *Handler) MQTTAcl(w http.ResponseWriter, r *http.Request) {
|
||
var req mqttAclRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
|
||
return
|
||
}
|
||
|
||
// Специальный случай: mqtt-bridge подписывается на "+/telemetry/+" (все тенанты).
|
||
// Публикация bridge НЕ разрешена — только чтение.
|
||
if req.ClientID == "sless-iot-bridge" {
|
||
if req.Action == "subscribe" {
|
||
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "allow"})
|
||
} else {
|
||
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
|
||
}
|
||
return
|
||
}
|
||
|
||
// Парсим username → namespace + deviceId (формат: "{ns}_{deviceId}")
|
||
// strings.Index находит ПЕРВЫЙ '_' — namespace содержит только дефисы
|
||
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
|
||
}
|
||
|
||
// Разрешённый топик: "{ns}/telemetry/{deviceId}"
|
||
// Это то что эмулятор строит как: topicPrefix + "telemetry/" + device_id
|
||
// topicPrefix = "{ns}/" → итого "{ns}/telemetry/{deviceId}"
|
||
allowedTopic := ns + "/telemetry/" + deviceID
|
||
if req.Topic == allowedTopic {
|
||
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "allow"})
|
||
return
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
|
||
}
|