feat: перенос IoT managed service из sless в отдельную репу

Компоненты:
- 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 адаптированы.
This commit is contained in:
Naeel
2026-04-12 14:29:43 +03:00
commit 1a94241c62
31 changed files with 7023 additions and 0 deletions
+454
View File
@@ -0,0 +1,454 @@
// Создано: 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"})
}